diff --git a/CHANGELOG.md b/CHANGELOG.md index e4496968a21..ddae8cc2207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Bind gateway approval access to requester metadata [AI]. (#81380) Thanks @pgondhi987. - Telegram: let isolated polling drain independent topics, DMs, and status/control commands concurrently while preserving same-lane order. (#81849) Thanks @VACInc. - Ollama/Doctor: copy explicit native Ollama `contextWindow` or `maxTokens` provider/model budgets into `params.num_ctx` during `openclaw doctor --fix`, preserving large-context configs after native Ollama stopped inferring per-request `num_ctx`. Fixes #81878. (#81928) Thanks @joshavant and @ArthurusDent. +- Discord: honor `threadName` on `message send` to existing threads by renaming the thread after successful delivery, and warn when the rename cannot be applied. Fixes #81836. (#81933) Thanks @joshavant. - Doctor/Codex: stop warning that the message tool is unavailable for source-reply paths where OpenClaw grants `message` at runtime, keeping update and doctor output aligned with the OpenAI happy path. Thanks @pashpashpash. - Build: keep externalized Slack, OpenShell sandbox, and Anthropic Vertex runtime dependency declarations out of the root dist artifact build. - Auto-reply/Claude CLI: bridge CLI-runtime assistant text-delta agent events into the chat reasoning preview through `onReasoningStream`, mirroring the existing assistant-text (#76914) and tool-event (#80046) bridges and adding gating so non-CLI runtimes are unaffected. Thanks @anagnorisis2peripeteia and @pashpashpash. diff --git a/extensions/discord/src/actions/handle-action.test.ts b/extensions/discord/src/actions/handle-action.test.ts index bee518cfa30..007d7ba2087 100644 --- a/extensions/discord/src/actions/handle-action.test.ts +++ b/extensions/discord/src/actions/handle-action.test.ts @@ -167,6 +167,40 @@ describe("handleDiscordMessageAction", () => { }); }); + it("forwards threadName on sends", async () => { + const cfg = discordConfig(); + await handleDiscordMessageAction({ + action: "send", + params: { + target: "channel:thread-1", + message: "hello", + threadName: "Renamed thread", + }, + cfg, + }); + + expectDiscordActionCall({ + payload: { + action: "sendMessage", + accountId: undefined, + to: "channel:thread-1", + content: "hello", + threadName: "Renamed thread", + mediaUrl: undefined, + filename: undefined, + replyTo: undefined, + components: undefined, + embeds: undefined, + asVoice: false, + silent: false, + __sessionKey: undefined, + __agentId: undefined, + }, + cfg, + options: defaultActionOptions(), + }); + }); + it("maps upload-file to Discord sendMessage with media read context", async () => { const mediaReadFile = vi.fn(async () => Buffer.from("image")); const mediaAccess = { diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index 30a703a2379..473447a02a8 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -104,12 +104,14 @@ export async function handleDiscordMessageAction( const silent = readBooleanParam(params, "silent") === true; const sessionKey = readStringParam(params, "__sessionKey"); const agentId = readStringParam(params, "__agentId"); + const threadName = readStringParam(params, "threadName"); return await handleDiscordAction( { action: "sendMessage", accountId: accountId ?? undefined, to, content: content ?? "", + ...(threadName ? { threadName } : {}), mediaUrl: mediaUrl ?? undefined, filename: filename ?? undefined, replyTo: replyTo ?? undefined, diff --git a/extensions/discord/src/actions/runtime.messaging.runtime.ts b/extensions/discord/src/actions/runtime.messaging.runtime.ts index 9c8f14a5148..81a22c4387a 100644 --- a/extensions/discord/src/actions/runtime.messaging.runtime.ts +++ b/extensions/discord/src/actions/runtime.messaging.runtime.ts @@ -5,6 +5,8 @@ import { createThreadDiscord, deleteMessageDiscord, editMessageDiscord, + editChannelDiscord, + fetchChannelInfoDiscord, fetchChannelPermissionsDiscord, fetchMessageDiscord, fetchReactionsDiscord, @@ -28,7 +30,9 @@ import { resolveDiscordChannelId } from "../targets.js"; export const discordMessagingActionRuntime = { createThreadDiscord, deleteMessageDiscord, + editChannelDiscord, editMessageDiscord, + fetchChannelInfoDiscord, fetchChannelPermissionsDiscord, fetchMessageDiscord, fetchReactionsDiscord, diff --git a/extensions/discord/src/actions/runtime.messaging.send.ts b/extensions/discord/src/actions/runtime.messaging.send.ts index 35814685408..49c939e9ea4 100644 --- a/extensions/discord/src/actions/runtime.messaging.send.ts +++ b/extensions/discord/src/actions/runtime.messaging.send.ts @@ -1,3 +1,4 @@ +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { assertMediaNotDataUrl, jsonResult, @@ -8,6 +9,7 @@ import { resolvePollMaxSelections, } from "../runtime-api.js"; import { DiscordThreadInitialMessageError } from "../send.js"; +import { isThreadChannelType } from "../send.permissions.js"; import type { DiscordSendComponents, DiscordSendEmbeds } from "../send.shared.js"; import { discordMessagingActionRuntime } from "./runtime.messaging.runtime.js"; import type { DiscordMessagingActionContext } from "./runtime.messaging.shared.js"; @@ -21,6 +23,69 @@ function hasDiscordComponentObjectKeys(value: unknown): value is Record; + target: string; + threadName?: string; + }, +) { + const threadName = params.threadName?.trim(); + if (!threadName) { + return params.payload; + } + if (!ctx.isActionEnabled("channels")) { + return { + ...params.payload, + warning: "Discord threadName was ignored because Discord channel management is disabled.", + }; + } + + let channelId: string; + try { + channelId = discordMessagingActionRuntime.resolveDiscordChannelId(params.target); + } catch { + return { + ...params.payload, + warning: "Discord threadName was ignored because the send target is not a channel/thread.", + }; + } + + try { + const channel = await discordMessagingActionRuntime.fetchChannelInfoDiscord( + channelId, + ctx.withOpts(), + ); + if (!isThreadChannelType(channel.type)) { + return { + ...params.payload, + warning: "Discord threadName was ignored because the send target is not a thread.", + }; + } + const renamed = await discordMessagingActionRuntime.editChannelDiscord( + { + channelId, + name: threadName, + }, + ctx.withOpts(), + ); + return { + ...params.payload, + threadRename: { + ok: true, + channelId, + name: renamed.name ?? threadName, + }, + }; + } catch (error) { + return { + ...params.payload, + warning: `Discord message was sent, but thread rename failed: ${formatErrorMessage(error)}`, + }; + } +} + export async function handleDiscordMessageSendAction(ctx: DiscordMessagingActionContext) { switch (ctx.action) { case "sticker": { @@ -88,6 +153,7 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction }); const filename = readStringParam(ctx.params, "filename"); const replyTo = readStringParam(ctx.params, "replyTo"); + const threadName = readStringParam(ctx.params, "threadName"); const rawEmbeds = ctx.params.embeds; const embeds: DiscordSendEmbeds | undefined = Array.isArray(rawEmbeds) ? (rawEmbeds as DiscordSendEmbeds) @@ -122,7 +188,13 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction mediaReadFile: ctx.options?.mediaReadFile, }, ); - return jsonResult({ ok: true, result, components: true }); + return jsonResult( + await appendDiscordThreadRenameResult(ctx, { + payload: { ok: true, result, components: true }, + target: to, + threadName, + }), + ); } if (asVoice) { @@ -142,7 +214,13 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction replyTo, silent, }); - return jsonResult({ ok: true, result, voiceMessage: true }); + return jsonResult( + await appendDiscordThreadRenameResult(ctx, { + payload: { ok: true, result, voiceMessage: true }, + target: to, + threadName, + }), + ); } const result = await discordMessagingActionRuntime.sendMessageDiscord(to, content ?? "", { @@ -157,7 +235,13 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction embeds, silent, }); - return jsonResult({ ok: true, result }); + return jsonResult( + await appendDiscordThreadRenameResult(ctx, { + payload: { ok: true, result }, + target: to, + threadName, + }), + ); } case "threadCreate": { if (!ctx.isActionEnabled("threads")) { diff --git a/extensions/discord/src/actions/runtime.test.ts b/extensions/discord/src/actions/runtime.test.ts index 337319c0271..a1dd6cb4554 100644 --- a/extensions/discord/src/actions/runtime.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -34,6 +34,7 @@ const discordSendMocks = { name: "edited", })), editMessageDiscord: vi.fn(async () => ({})), + fetchChannelInfoDiscord: vi.fn(async () => ({ id: "C1", type: 0 })), fetchChannelPermissionsDiscord: vi.fn(async () => ({})), fetchMessageDiscord: vi.fn(async () => ({})), fetchReactionsDiscord: vi.fn(async () => ({})), @@ -64,6 +65,7 @@ const { createThreadDiscord, deleteChannelDiscord, editChannelDiscord, + fetchChannelInfoDiscord, fetchReactionsDiscord, fetchMessageDiscord, kickMemberDiscord, @@ -594,6 +596,166 @@ describe("handleDiscordMessagingAction", () => { expect(sendOptions.filename).toBe("image.png"); }); + it("renames an existing thread when threadName is provided on sendMessage", async () => { + sendMessageDiscord.mockResolvedValueOnce({ + messageId: "M1", + channelId: "T1", + }); + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "T1", + type: 11, + }); + editChannelDiscord.mockResolvedValueOnce({ + id: "T1", + name: "new-thread", + }); + + const result = await handleMessagingAction( + "sendMessage", + { + to: "channel:T1", + content: "hello", + threadName: "new-thread", + }, + enableAllActions, + ); + + expect(sendMessageDiscord).toHaveBeenCalledWith("channel:T1", "hello", { + cfg: DISCORD_TEST_CFG, + accountId: undefined, + mediaAccess: undefined, + mediaUrl: undefined, + filename: undefined, + mediaLocalRoots: undefined, + mediaReadFile: undefined, + replyTo: undefined, + components: undefined, + embeds: undefined, + silent: false, + }); + expect(fetchChannelInfoDiscord).toHaveBeenCalledWith("T1", { cfg: DISCORD_TEST_CFG }); + expect(editChannelDiscord).toHaveBeenCalledWith( + { + channelId: "T1", + name: "new-thread", + }, + { cfg: DISCORD_TEST_CFG }, + ); + expect(result.details).toEqual({ + ok: true, + result: { + messageId: "M1", + channelId: "T1", + }, + threadRename: { + ok: true, + channelId: "T1", + name: "new-thread", + }, + }); + }); + + it("warns instead of renaming when threadName is provided but channel management is disabled", async () => { + sendMessageDiscord.mockResolvedValueOnce({ + messageId: "M1", + channelId: "T1", + }); + + const messagesOnly = (key: keyof DiscordActionConfig) => key === "messages"; + const result = await handleMessagingAction( + "sendMessage", + { + to: "channel:T1", + content: "hello", + threadName: "new-thread", + }, + messagesOnly, + ); + + expect(sendMessageDiscord).toHaveBeenCalledTimes(1); + expect(fetchChannelInfoDiscord).not.toHaveBeenCalled(); + expect(editChannelDiscord).not.toHaveBeenCalled(); + expect(result.details).toEqual({ + ok: true, + result: { + messageId: "M1", + channelId: "T1", + }, + warning: "Discord threadName was ignored because Discord channel management is disabled.", + }); + }); + + it("warns instead of renaming when threadName is provided for a non-thread send target", async () => { + sendMessageDiscord.mockResolvedValueOnce({ + messageId: "M1", + channelId: "C1", + }); + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "C1", + type: 0, + }); + + const result = await handleMessagingAction( + "sendMessage", + { + to: "channel:C1", + content: "hello", + threadName: "new-thread", + }, + enableAllActions, + ); + + expect(fetchChannelInfoDiscord).toHaveBeenCalledWith("C1", { cfg: DISCORD_TEST_CFG }); + expect(editChannelDiscord).not.toHaveBeenCalled(); + expect(result.details).toEqual({ + ok: true, + result: { + messageId: "M1", + channelId: "C1", + }, + warning: "Discord threadName was ignored because the send target is not a thread.", + }); + }); + + it("preserves message delivery and warns when thread rename fails", async () => { + sendMessageDiscord.mockResolvedValueOnce({ + messageId: "M1", + channelId: "T1", + }); + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "T1", + type: 11, + }); + editChannelDiscord.mockRejectedValueOnce(new Error("missing permissions")); + + const result = await handleMessagingAction( + "sendMessage", + { + to: "channel:T1", + content: "hello", + threadName: "new-thread", + }, + enableAllActions, + ); + + expect(sendMessageDiscord).toHaveBeenCalledTimes(1); + expect(editChannelDiscord).toHaveBeenCalledWith( + { + channelId: "T1", + name: "new-thread", + }, + { cfg: DISCORD_TEST_CFG }, + ); + expect(result.details).toEqual({ + ok: true, + result: { + messageId: "M1", + channelId: "T1", + }, + warning: "Discord message was sent, but thread rename failed: missing permissions", + }); + }); + it("rejects voice messages that include content", async () => { await expect( handleMessagingAction(