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
|
### 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
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") {
|
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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: [] });
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user