fix(discord): honor threadName when sending to threads (#81933)

This commit is contained in:
Josh Avant
2026-05-14 17:07:29 -05:00
committed by GitHub
parent 3f0a39510b
commit bcbf4fc35f
6 changed files with 290 additions and 3 deletions

View File

@@ -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.

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<string,
);
}
async function appendDiscordThreadRenameResult(
ctx: DiscordMessagingActionContext,
params: {
payload: Record<string, unknown>;
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")) {

View File

@@ -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(