From 9949f82590ebf869f0ae4e16473bef957adea57a Mon Sep 17 00:00:00 2001 From: jarvis89757 Date: Sun, 8 Feb 2026 16:51:10 +1100 Subject: [PATCH] fix(discord): support forum channel thread-create (#10062) * fix(discord): support forum channel thread-create * fix: harden discord forum thread-create (#10062) (thanks @jarvis89757) --------- Co-authored-by: Shakker --- CHANGELOG.md | 1 + docs/cli/message.md | 2 +- src/agents/tools/discord-actions-messaging.ts | 5 +- src/agents/tools/discord-actions.test.ts | 19 +++++++ .../actions/discord/handle-action.test.ts | 35 ++++++++++++ .../plugins/actions/discord/handle-action.ts | 2 + src/cli/program/message/register.thread.ts | 1 + src/discord/send.creates-thread.test.ts | 53 ++++++++++++++++++- src/discord/send.messages.ts | 25 +++++++-- src/discord/send.types.ts | 1 + 10 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 src/channels/plugins/actions/discord/handle-action.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index abd96e0527..afa038b927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord: support forum/media `thread create` starter messages, wire `message thread create --message`, and harden thread-create routing. (#10062) Thanks @jarvis89757. - Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh. - Cron: route text-only isolated agent announces through the shared subagent announce flow; add exponential backoff for repeated errors; preserve future `nextRunAtMs` on restart; include current-boundary schedule matches; prevent stale threadId reuse across targets; and add per-job execution timeout. (#11641) Thanks @tyler6204. - Subagents: stabilize announce timing, preserve compaction metrics across retries, clamp overflow-prone long timeouts, and cap impossible context usage token totals. (#11551) Thanks @tyler6204. diff --git a/docs/cli/message.md b/docs/cli/message.md index 3b6c850800..5e5779dd64 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -118,7 +118,7 @@ Name lookup: - `thread create` - Channels: Discord - Required: `--thread-name`, `--target` (channel id) - - Optional: `--message-id`, `--auto-archive-min` + - Optional: `--message-id`, `--message`, `--auto-archive-min` - `thread list` - Channels: Discord diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 3a39cc248d..60fcb23495 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -281,6 +281,7 @@ export async function handleDiscordMessagingAction( const channelId = resolveChannelId(); const name = readStringParam(params, "name", { required: true }); const messageId = readStringParam(params, "messageId"); + const content = readStringParam(params, "content"); const autoArchiveMinutesRaw = params.autoArchiveMinutes; const autoArchiveMinutes = typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw) @@ -289,10 +290,10 @@ export async function handleDiscordMessagingAction( const thread = accountId ? await createThreadDiscord( channelId, - { name, messageId, autoArchiveMinutes }, + { name, messageId, autoArchiveMinutes, content }, { accountId }, ) - : await createThreadDiscord(channelId, { name, messageId, autoArchiveMinutes }); + : await createThreadDiscord(channelId, { name, messageId, autoArchiveMinutes, content }); return jsonResult({ ok: true, thread }); } case "threadList": { diff --git a/src/agents/tools/discord-actions.test.ts b/src/agents/tools/discord-actions.test.ts index a36de127f1..c156d0c57d 100644 --- a/src/agents/tools/discord-actions.test.ts +++ b/src/agents/tools/discord-actions.test.ts @@ -234,6 +234,25 @@ describe("handleDiscordMessagingAction", () => { new Date(expectedMs).toISOString(), ); }); + + it("forwards optional thread content", async () => { + createThreadDiscord.mockClear(); + await handleDiscordMessagingAction( + "threadCreate", + { + channelId: "C1", + name: "Forum thread", + content: "Initial forum post body", + }, + enableAllActions, + ); + expect(createThreadDiscord).toHaveBeenCalledWith("C1", { + name: "Forum thread", + messageId: undefined, + autoArchiveMinutes: undefined, + content: "Initial forum post body", + }); + }); }); const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels"; diff --git a/src/channels/plugins/actions/discord/handle-action.test.ts b/src/channels/plugins/actions/discord/handle-action.test.ts new file mode 100644 index 0000000000..927f6fdcbd --- /dev/null +++ b/src/channels/plugins/actions/discord/handle-action.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { handleDiscordMessageAction } from "./handle-action.js"; + +const handleDiscordAction = vi.fn(async () => ({ details: { ok: true } })); + +vi.mock("../../../../agents/tools/discord-actions.js", () => ({ + handleDiscordAction: (...args: unknown[]) => handleDiscordAction(...args), +})); + +describe("handleDiscordMessageAction", () => { + beforeEach(() => { + handleDiscordAction.mockClear(); + }); + + it("forwards thread-create message as content", async () => { + await handleDiscordMessageAction({ + action: "thread-create", + params: { + to: "channel:123456789", + threadName: "Forum thread", + message: "Initial forum post body", + }, + cfg: {}, + }); + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "threadCreate", + channelId: "123456789", + name: "Forum thread", + content: "Initial forum post body", + }), + expect.any(Object), + ); + }); +}); diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts index 04ade812ea..1e71796719 100644 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ b/src/channels/plugins/actions/discord/handle-action.ts @@ -184,6 +184,7 @@ export async function handleDiscordMessageAction( if (action === "thread-create") { const name = readStringParam(params, "threadName", { required: true }); const messageId = readStringParam(params, "messageId"); + const content = readStringParam(params, "message"); const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", { integer: true, }); @@ -194,6 +195,7 @@ export async function handleDiscordMessageAction( channelId: resolveChannelId(), name, messageId, + content, autoArchiveMinutes, }, cfg, diff --git a/src/cli/program/message/register.thread.ts b/src/cli/program/message/register.thread.ts index e58cb55af4..7d4365a991 100644 --- a/src/cli/program/message/register.thread.ts +++ b/src/cli/program/message/register.thread.ts @@ -14,6 +14,7 @@ export function registerMessageThreadCommands(message: Command, helpers: Message ), ) .option("--message-id ", "Message id (optional)") + .option("-m, --message ", "Initial thread message text") .option("--auto-archive-min ", "Thread auto-archive minutes") .action(async (opts) => { await helpers.runMessageAction("thread-create", opts); diff --git a/src/discord/send.creates-thread.test.ts b/src/discord/send.creates-thread.test.ts index 3bd2d4a04b..3b332c06bc 100644 --- a/src/discord/send.creates-thread.test.ts +++ b/src/discord/send.creates-thread.test.ts @@ -1,5 +1,5 @@ import { RateLimitError } from "@buape/carbon"; -import { Routes } from "discord-api-types/v10"; +import { ChannelType, Routes } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { addRoleDiscord, @@ -60,15 +60,64 @@ describe("sendMessageDiscord", () => { }); it("creates a thread", async () => { - const { rest, postMock } = makeRest(); + const { rest, getMock, postMock } = makeRest(); postMock.mockResolvedValue({ id: "t1" }); await createThreadDiscord("chan1", { name: "thread", messageId: "m1" }, { rest, token: "t" }); + expect(getMock).not.toHaveBeenCalled(); expect(postMock).toHaveBeenCalledWith( Routes.threads("chan1", "m1"), expect.objectContaining({ body: { name: "thread" } }), ); }); + it("creates forum threads with an initial message", async () => { + const { rest, getMock, postMock } = makeRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildForum }); + postMock.mockResolvedValue({ id: "t1" }); + await createThreadDiscord("chan1", { name: "thread" }, { rest, token: "t" }); + expect(getMock).toHaveBeenCalledWith(Routes.channel("chan1")); + expect(postMock).toHaveBeenCalledWith( + Routes.threads("chan1"), + expect.objectContaining({ + body: { + name: "thread", + message: { content: "thread" }, + }, + }), + ); + }); + + it("creates media threads with provided content", async () => { + const { rest, getMock, postMock } = makeRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildMedia }); + postMock.mockResolvedValue({ id: "t1" }); + await createThreadDiscord( + "chan1", + { name: "thread", content: "initial forum post" }, + { rest, token: "t" }, + ); + expect(postMock).toHaveBeenCalledWith( + Routes.threads("chan1"), + expect.objectContaining({ + body: { + name: "thread", + message: { content: "initial forum post" }, + }, + }), + ); + }); + + it("falls back when channel lookup is unavailable", async () => { + const { rest, getMock, postMock } = makeRest(); + getMock.mockRejectedValue(new Error("lookup failed")); + postMock.mockResolvedValue({ id: "t1" }); + await createThreadDiscord("chan1", { name: "thread" }, { rest, token: "t" }); + expect(postMock).toHaveBeenCalledWith( + Routes.threads("chan1"), + expect.objectContaining({ body: { name: "thread" } }), + ); + }); + it("lists active threads by guild", async () => { const { rest, getMock } = makeRest(); getMock.mockResolvedValue({ threads: [] }); diff --git a/src/discord/send.messages.ts b/src/discord/send.messages.ts index f0899bbe9d..bd8bcf2bb1 100644 --- a/src/discord/send.messages.ts +++ b/src/discord/send.messages.ts @@ -1,5 +1,5 @@ -import type { APIMessage } from "discord-api-types/v10"; -import { Routes } from "discord-api-types/v10"; +import type { APIChannel, APIMessage } from "discord-api-types/v10"; +import { ChannelType, Routes } from "discord-api-types/v10"; import type { DiscordMessageEdit, DiscordMessageQuery, @@ -105,7 +105,26 @@ export async function createThreadDiscord( if (payload.autoArchiveMinutes) { body.auto_archive_duration = payload.autoArchiveMinutes; } - const route = Routes.threads(channelId, payload.messageId); + let channelType: ChannelType | undefined; + if (!payload.messageId) { + // Only detect channel kind for route-less thread creation. + // If this lookup fails, keep prior behavior and let Discord validate. + try { + const channel = (await rest.get(Routes.channel(channelId))) as APIChannel | null | undefined; + channelType = channel?.type; + } catch { + channelType = undefined; + } + } + const isForumLike = + channelType === ChannelType.GuildForum || channelType === ChannelType.GuildMedia; + if (isForumLike) { + const starterContent = payload.content?.trim() ? payload.content : payload.name; + body.message = { content: starterContent }; + } + const route = payload.messageId + ? Routes.threads(channelId, payload.messageId) + : Routes.threads(channelId); return await rest.post(route, { body }); } diff --git a/src/discord/send.types.ts b/src/discord/send.types.ts index 1a31d42ace..5a171a7566 100644 --- a/src/discord/send.types.ts +++ b/src/discord/send.types.ts @@ -71,6 +71,7 @@ export type DiscordThreadCreate = { messageId?: string; name: string; autoArchiveMinutes?: number; + content?: string; }; export type DiscordThreadList = {