mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 17:34:45 +00:00
fix(discord): honor threadName when sending to threads (#81933)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user