mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
Discord: harden forum thread sends (#12380) (thanks @magendary)
This commit is contained in:
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
|
||||
- WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez.
|
||||
- Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.
|
||||
- Telegram: recover proactive sends when stale topic thread IDs are used by retrying without `message_thread_id`. (#11620)
|
||||
- Discord: auto-create forum/media thread posts on send, with chunked follow-up replies and media handling for forum sends. (#12380) Thanks @magendary, @thewilloftheshadow.
|
||||
- Telegram: render markdown spoilers with `<tg-spoiler>` HTML tags. (#11543) Thanks @ezhikkk.
|
||||
- Telegram: truncate command registration to 100 entries to avoid `BOT_COMMANDS_TOO_MUCH` failures on startup. (#12356) Thanks @arosstale.
|
||||
- Telegram: match DM `allowFrom` against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { convertMarkdownTables } from "../markdown/tables.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import {
|
||||
buildDiscordSendError,
|
||||
buildDiscordTextChunks,
|
||||
createDiscordClient,
|
||||
normalizeDiscordPollInput,
|
||||
normalizeStickerIds,
|
||||
@@ -81,32 +82,28 @@ export async function sendMessageDiscord(
|
||||
}
|
||||
|
||||
if (isForumLikeType(channelType)) {
|
||||
const threadName = deriveForumThreadName(textWithTables);
|
||||
const chunks = buildDiscordTextChunks(textWithTables, {
|
||||
maxLinesPerMessage: accountInfo.config.maxLinesPerMessage,
|
||||
chunkMode,
|
||||
});
|
||||
const starterContent = chunks[0]?.trim() ? chunks[0] : threadName;
|
||||
const starterEmbeds = opts.embeds?.length ? opts.embeds : undefined;
|
||||
let threadRes: { id: string; message?: { id: string; channel_id: string } };
|
||||
try {
|
||||
const threadName = deriveForumThreadName(textWithTables);
|
||||
const starterContent = textWithTables.trim() || threadName;
|
||||
const threadRes = (await request(
|
||||
threadRes = (await request(
|
||||
() =>
|
||||
rest.post(Routes.threads(channelId), {
|
||||
body: {
|
||||
name: threadName,
|
||||
message: { content: starterContent },
|
||||
message: {
|
||||
content: starterContent,
|
||||
...(starterEmbeds ? { embeds: starterEmbeds } : {}),
|
||||
},
|
||||
},
|
||||
}) as Promise<{ id: string; message?: { id: string; channel_id: string } }>,
|
||||
"forum-thread",
|
||||
)) as { id: string; message?: { id: string; channel_id: string } };
|
||||
|
||||
recordChannelActivity({
|
||||
channel: "discord",
|
||||
accountId: accountInfo.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
const threadId = threadRes.id;
|
||||
const messageId = threadRes.message?.id ?? threadId;
|
||||
const resultChannelId = threadRes.message?.channel_id ?? threadId;
|
||||
return {
|
||||
messageId: messageId ? String(messageId) : "unknown",
|
||||
channelId: String(resultChannelId ?? channelId),
|
||||
};
|
||||
} catch (err) {
|
||||
throw await buildDiscordSendError(err, {
|
||||
channelId,
|
||||
@@ -115,6 +112,70 @@ export async function sendMessageDiscord(
|
||||
hasMedia: Boolean(opts.mediaUrl),
|
||||
});
|
||||
}
|
||||
|
||||
const threadId = threadRes.id;
|
||||
const messageId = threadRes.message?.id ?? threadId;
|
||||
const resultChannelId = threadRes.message?.channel_id ?? threadId;
|
||||
const remainingChunks = chunks.slice(1);
|
||||
|
||||
try {
|
||||
if (opts.mediaUrl) {
|
||||
const [mediaCaption, ...afterMediaChunks] = remainingChunks;
|
||||
await sendDiscordMedia(
|
||||
rest,
|
||||
threadId,
|
||||
mediaCaption ?? "",
|
||||
opts.mediaUrl,
|
||||
undefined,
|
||||
request,
|
||||
accountInfo.config.maxLinesPerMessage,
|
||||
undefined,
|
||||
chunkMode,
|
||||
);
|
||||
for (const chunk of afterMediaChunks) {
|
||||
await sendDiscordText(
|
||||
rest,
|
||||
threadId,
|
||||
chunk,
|
||||
undefined,
|
||||
request,
|
||||
accountInfo.config.maxLinesPerMessage,
|
||||
undefined,
|
||||
chunkMode,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const chunk of remainingChunks) {
|
||||
await sendDiscordText(
|
||||
rest,
|
||||
threadId,
|
||||
chunk,
|
||||
undefined,
|
||||
request,
|
||||
accountInfo.config.maxLinesPerMessage,
|
||||
undefined,
|
||||
chunkMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw await buildDiscordSendError(err, {
|
||||
channelId: threadId,
|
||||
rest,
|
||||
token,
|
||||
hasMedia: Boolean(opts.mediaUrl),
|
||||
});
|
||||
}
|
||||
|
||||
recordChannelActivity({
|
||||
channel: "discord",
|
||||
accountId: accountInfo.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return {
|
||||
messageId: messageId ? String(messageId) : "unknown",
|
||||
channelId: String(resultChannelId ?? channelId),
|
||||
};
|
||||
}
|
||||
|
||||
let result: { id: string; channel_id: string } | { id: string | null; channel_id: string };
|
||||
|
||||
@@ -101,6 +101,64 @@ describe("sendMessageDiscord", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("posts media as a follow-up message in forum channels", async () => {
|
||||
const { rest, postMock, getMock } = makeRest();
|
||||
getMock.mockResolvedValueOnce({ type: ChannelType.GuildForum });
|
||||
postMock
|
||||
.mockResolvedValueOnce({
|
||||
id: "thread1",
|
||||
message: { id: "starter1", channel_id: "thread1" },
|
||||
})
|
||||
.mockResolvedValueOnce({ id: "media1", channel_id: "thread1" });
|
||||
const res = await sendMessageDiscord("channel:forum1", "Topic", {
|
||||
rest,
|
||||
token: "t",
|
||||
mediaUrl: "file:///tmp/photo.jpg",
|
||||
});
|
||||
expect(res).toEqual({ messageId: "starter1", channelId: "thread1" });
|
||||
expect(postMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
Routes.threads("forum1"),
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
name: "Topic",
|
||||
message: { content: "Topic" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(postMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
Routes.channelMessages("thread1"),
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
files: [expect.objectContaining({ name: "photo.jpg" })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("chunks long forum posts into follow-up messages", async () => {
|
||||
const { rest, postMock, getMock } = makeRest();
|
||||
getMock.mockResolvedValueOnce({ type: ChannelType.GuildForum });
|
||||
postMock
|
||||
.mockResolvedValueOnce({
|
||||
id: "thread1",
|
||||
message: { id: "starter1", channel_id: "thread1" },
|
||||
})
|
||||
.mockResolvedValueOnce({ id: "msg2", channel_id: "thread1" });
|
||||
const longText = "a".repeat(2001);
|
||||
await sendMessageDiscord("channel:forum1", longText, {
|
||||
rest,
|
||||
token: "t",
|
||||
});
|
||||
const firstBody = postMock.mock.calls[0]?.[1]?.body as {
|
||||
message?: { content?: string };
|
||||
};
|
||||
const secondBody = postMock.mock.calls[1]?.[1]?.body as { content?: string };
|
||||
expect(firstBody?.message?.content).toHaveLength(2000);
|
||||
expect(secondBody?.content).toBe("a");
|
||||
});
|
||||
|
||||
it("starts DM when recipient is a user", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock
|
||||
@@ -145,6 +203,7 @@ describe("sendMessageDiscord", () => {
|
||||
});
|
||||
postMock.mockRejectedValueOnce(apiError);
|
||||
getMock
|
||||
.mockResolvedValueOnce({ type: ChannelType.GuildText })
|
||||
.mockResolvedValueOnce({
|
||||
id: "789",
|
||||
guild_id: "guild1",
|
||||
|
||||
@@ -278,6 +278,24 @@ async function resolveChannelId(
|
||||
return { channelId: dmChannel.id, dm: true };
|
||||
}
|
||||
|
||||
export function buildDiscordTextChunks(
|
||||
text: string,
|
||||
opts: { maxLinesPerMessage?: number; chunkMode?: ChunkMode; maxChars?: number } = {},
|
||||
): string[] {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const chunks = chunkDiscordTextWithMode(text, {
|
||||
maxChars: opts.maxChars ?? DISCORD_TEXT_LIMIT,
|
||||
maxLines: opts.maxLinesPerMessage,
|
||||
chunkMode: opts.chunkMode,
|
||||
});
|
||||
if (!chunks.length && text) {
|
||||
chunks.push(text);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function sendDiscordText(
|
||||
rest: RequestClient,
|
||||
channelId: string,
|
||||
@@ -292,14 +310,7 @@ async function sendDiscordText(
|
||||
throw new Error("Message must be non-empty for Discord sends");
|
||||
}
|
||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||
const chunks = chunkDiscordTextWithMode(text, {
|
||||
maxChars: DISCORD_TEXT_LIMIT,
|
||||
maxLines: maxLinesPerMessage,
|
||||
chunkMode,
|
||||
});
|
||||
if (!chunks.length && text) {
|
||||
chunks.push(text);
|
||||
}
|
||||
const chunks = buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode });
|
||||
if (chunks.length === 1) {
|
||||
const res = (await request(
|
||||
() =>
|
||||
@@ -348,16 +359,7 @@ async function sendDiscordMedia(
|
||||
chunkMode?: ChunkMode,
|
||||
) {
|
||||
const media = await loadWebMedia(mediaUrl);
|
||||
const chunks = text
|
||||
? chunkDiscordTextWithMode(text, {
|
||||
maxChars: DISCORD_TEXT_LIMIT,
|
||||
maxLines: maxLinesPerMessage,
|
||||
chunkMode,
|
||||
})
|
||||
: [];
|
||||
if (!chunks.length && text) {
|
||||
chunks.push(text);
|
||||
}
|
||||
const chunks = text ? buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode }) : [];
|
||||
const caption = chunks[0] ?? "";
|
||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||
const res = (await request(
|
||||
@@ -408,6 +410,7 @@ function formatReactionEmoji(emoji: { id?: string | null; name?: string | null }
|
||||
|
||||
export {
|
||||
buildDiscordSendError,
|
||||
buildDiscordTextChunks,
|
||||
buildReactionIdentifier,
|
||||
createDiscordClient,
|
||||
formatReactionEmoji,
|
||||
|
||||
Reference in New Issue
Block a user