mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-08 21:09:23 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
35
src/channels/plugins/actions/discord/handle-action.test.ts
Normal file
35
src/channels/plugins/actions/discord/handle-action.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -14,6 +14,7 @@ export function registerMessageThreadCommands(message: Command, helpers: Message
|
||||
),
|
||||
)
|
||||
.option("--message-id <id>", "Message id (optional)")
|
||||
.option("-m, --message <text>", "Initial thread message text")
|
||||
.option("--auto-archive-min <n>", "Thread auto-archive minutes")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("thread-create", opts);
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export type DiscordThreadCreate = {
|
||||
messageId?: string;
|
||||
name: string;
|
||||
autoArchiveMinutes?: number;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
export type DiscordThreadList = {
|
||||
|
||||
Reference in New Issue
Block a user