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 <shakkerdroid@gmail.com>
This commit is contained in:
jarvis89757
2026-02-08 16:51:10 +11:00
committed by GitHub
parent bc475f0172
commit 9949f82590
10 changed files with 136 additions and 8 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
### Fixes ### 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. - 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. - 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. - Subagents: stabilize announce timing, preserve compaction metrics across retries, clamp overflow-prone long timeouts, and cap impossible context usage token totals. (#11551) Thanks @tyler6204.

View File

@@ -118,7 +118,7 @@ Name lookup:
- `thread create` - `thread create`
- Channels: Discord - Channels: Discord
- Required: `--thread-name`, `--target` (channel id) - Required: `--thread-name`, `--target` (channel id)
- Optional: `--message-id`, `--auto-archive-min` - Optional: `--message-id`, `--message`, `--auto-archive-min`
- `thread list` - `thread list`
- Channels: Discord - Channels: Discord

View File

@@ -281,6 +281,7 @@ export async function handleDiscordMessagingAction(
const channelId = resolveChannelId(); const channelId = resolveChannelId();
const name = readStringParam(params, "name", { required: true }); const name = readStringParam(params, "name", { required: true });
const messageId = readStringParam(params, "messageId"); const messageId = readStringParam(params, "messageId");
const content = readStringParam(params, "content");
const autoArchiveMinutesRaw = params.autoArchiveMinutes; const autoArchiveMinutesRaw = params.autoArchiveMinutes;
const autoArchiveMinutes = const autoArchiveMinutes =
typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw) typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw)
@@ -289,10 +290,10 @@ export async function handleDiscordMessagingAction(
const thread = accountId const thread = accountId
? await createThreadDiscord( ? await createThreadDiscord(
channelId, channelId,
{ name, messageId, autoArchiveMinutes }, { name, messageId, autoArchiveMinutes, content },
{ accountId }, { accountId },
) )
: await createThreadDiscord(channelId, { name, messageId, autoArchiveMinutes }); : await createThreadDiscord(channelId, { name, messageId, autoArchiveMinutes, content });
return jsonResult({ ok: true, thread }); return jsonResult({ ok: true, thread });
} }
case "threadList": { case "threadList": {

View File

@@ -234,6 +234,25 @@ describe("handleDiscordMessagingAction", () => {
new Date(expectedMs).toISOString(), 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"; const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";

View File

@@ -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),
);
});
});

View File

@@ -184,6 +184,7 @@ export async function handleDiscordMessageAction(
if (action === "thread-create") { if (action === "thread-create") {
const name = readStringParam(params, "threadName", { required: true }); const name = readStringParam(params, "threadName", { required: true });
const messageId = readStringParam(params, "messageId"); const messageId = readStringParam(params, "messageId");
const content = readStringParam(params, "message");
const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", { const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", {
integer: true, integer: true,
}); });
@@ -194,6 +195,7 @@ export async function handleDiscordMessageAction(
channelId: resolveChannelId(), channelId: resolveChannelId(),
name, name,
messageId, messageId,
content,
autoArchiveMinutes, autoArchiveMinutes,
}, },
cfg, cfg,

View File

@@ -14,6 +14,7 @@ export function registerMessageThreadCommands(message: Command, helpers: Message
), ),
) )
.option("--message-id <id>", "Message id (optional)") .option("--message-id <id>", "Message id (optional)")
.option("-m, --message <text>", "Initial thread message text")
.option("--auto-archive-min <n>", "Thread auto-archive minutes") .option("--auto-archive-min <n>", "Thread auto-archive minutes")
.action(async (opts) => { .action(async (opts) => {
await helpers.runMessageAction("thread-create", opts); await helpers.runMessageAction("thread-create", opts);

View File

@@ -1,5 +1,5 @@
import { RateLimitError } from "@buape/carbon"; 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 { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
addRoleDiscord, addRoleDiscord,
@@ -60,15 +60,64 @@ describe("sendMessageDiscord", () => {
}); });
it("creates a thread", async () => { it("creates a thread", async () => {
const { rest, postMock } = makeRest(); const { rest, getMock, postMock } = makeRest();
postMock.mockResolvedValue({ id: "t1" }); postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord("chan1", { name: "thread", messageId: "m1" }, { rest, token: "t" }); await createThreadDiscord("chan1", { name: "thread", messageId: "m1" }, { rest, token: "t" });
expect(getMock).not.toHaveBeenCalled();
expect(postMock).toHaveBeenCalledWith( expect(postMock).toHaveBeenCalledWith(
Routes.threads("chan1", "m1"), Routes.threads("chan1", "m1"),
expect.objectContaining({ body: { name: "thread" } }), 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 () => { it("lists active threads by guild", async () => {
const { rest, getMock } = makeRest(); const { rest, getMock } = makeRest();
getMock.mockResolvedValue({ threads: [] }); getMock.mockResolvedValue({ threads: [] });

View File

@@ -1,5 +1,5 @@
import type { APIMessage } from "discord-api-types/v10"; import type { APIChannel, APIMessage } from "discord-api-types/v10";
import { Routes } from "discord-api-types/v10"; import { ChannelType, Routes } from "discord-api-types/v10";
import type { import type {
DiscordMessageEdit, DiscordMessageEdit,
DiscordMessageQuery, DiscordMessageQuery,
@@ -105,7 +105,26 @@ export async function createThreadDiscord(
if (payload.autoArchiveMinutes) { if (payload.autoArchiveMinutes) {
body.auto_archive_duration = 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 }); return await rest.post(route, { body });
} }

View File

@@ -71,6 +71,7 @@ export type DiscordThreadCreate = {
messageId?: string; messageId?: string;
name: string; name: string;
autoArchiveMinutes?: number; autoArchiveMinutes?: number;
content?: string;
}; };
export type DiscordThreadList = { export type DiscordThreadList = {