discord: auto-create thread when sending to Forum/Media channels (#12380)

* discord: auto-create thread when sending to Forum/Media channels

* Discord: harden forum thread sends (#12380) (thanks @magendary)

* fix: clean up discord send exports (#12380) (thanks @magendary)

---------

Co-authored-by: Shadow <shadow@clawd.bot>
This commit is contained in:
magendary
2026-02-09 18:26:42 -08:00
committed by GitHub
parent 6d26ba3bb6
commit ead3bb645f
5 changed files with 286 additions and 66 deletions

View File

@@ -1,5 +1,6 @@
import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import type { APIChannel } from "discord-api-types/v10";
import { ChannelType, Routes } from "discord-api-types/v10";
import type { RetryConfig } from "../infra/retry.js";
import type { PollInput } from "../polls.js";
import type { DiscordSendResult } from "./send.types.js";
@@ -11,6 +12,7 @@ import { convertMarkdownTables } from "../markdown/tables.js";
import { resolveDiscordAccount } from "./accounts.js";
import {
buildDiscordSendError,
buildDiscordTextChunks,
createDiscordClient,
normalizeDiscordPollInput,
normalizeStickerIds,
@@ -31,6 +33,24 @@ type DiscordSendOpts = {
embeds?: unknown[];
};
/** Discord thread names are capped at 100 characters. */
const DISCORD_THREAD_NAME_LIMIT = 100;
/** Derive a thread title from the first non-empty line of the message text. */
function deriveForumThreadName(text: string): string {
const firstLine =
text
.split("\n")
.find((l) => l.trim())
?.trim() ?? "";
return firstLine.slice(0, DISCORD_THREAD_NAME_LIMIT) || new Date().toISOString().slice(0, 16);
}
/** Forum/Media channels cannot receive regular messages; detect them here. */
function isForumLikeType(channelType?: number): boolean {
return channelType === ChannelType.GuildForum || channelType === ChannelType.GuildMedia;
}
export async function sendMessageDiscord(
to: string,
text: string,
@@ -51,6 +71,113 @@ export async function sendMessageDiscord(
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = await parseAndResolveRecipient(to, opts.accountId);
const { channelId } = await resolveChannelId(rest, recipient, request);
// Forum/Media channels reject POST /messages; auto-create a thread post instead.
let channelType: number | undefined;
try {
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel | undefined;
channelType = channel?.type;
} catch {
// If we can't fetch the channel, fall through to the normal send path.
}
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 {
threadRes = (await request(
() =>
rest.post(Routes.threads(channelId), {
body: {
name: threadName,
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 } };
} catch (err) {
throw await buildDiscordSendError(err, {
channelId,
rest,
token,
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 };
try {
if (opts.mediaUrl) {

View File

@@ -1,4 +1,4 @@
import { PermissionFlagsBits, Routes } from "discord-api-types/v10";
import { ChannelType, PermissionFlagsBits, Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
deleteMessageDiscord,
@@ -58,7 +58,9 @@ describe("sendMessageDiscord", () => {
});
it("sends basic channel messages", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock, getMock } = makeRest();
// Channel type lookup returns a normal text channel (not a forum).
getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
postMock.mockResolvedValue({
id: "msg1",
channel_id: "789",
@@ -74,6 +76,89 @@ describe("sendMessageDiscord", () => {
);
});
it("auto-creates a forum thread when target is a Forum channel", async () => {
const { rest, postMock, getMock } = makeRest();
// Channel type lookup returns a Forum channel.
getMock.mockResolvedValueOnce({ type: ChannelType.GuildForum });
postMock.mockResolvedValue({
id: "thread1",
message: { id: "starter1", channel_id: "thread1" },
});
const res = await sendMessageDiscord("channel:forum1", "Discussion topic\nBody of the post", {
rest,
token: "t",
});
expect(res).toEqual({ messageId: "starter1", channelId: "thread1" });
// Should POST to threads route, not channelMessages.
expect(postMock).toHaveBeenCalledWith(
Routes.threads("forum1"),
expect.objectContaining({
body: {
name: "Discussion topic",
message: { content: "Discussion topic\nBody of the post" },
},
}),
);
});
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
@@ -118,6 +203,7 @@ describe("sendMessageDiscord", () => {
});
postMock.mockRejectedValueOnce(apiError);
getMock
.mockResolvedValueOnce({ type: ChannelType.GuildText })
.mockResolvedValueOnce({
id: "789",
guild_id: "guild1",

View File

@@ -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(