import { ChannelType, type Client } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; import type { ReplyToMode } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { truncateUtf16Safe } from "../../utils.js"; import type { DiscordChannelConfigResolved } from "./allow-list.js"; import type { DiscordMessageEvent } from "./listeners.js"; import { resolveDiscordChannelInfo } from "./message-utils.js"; export type DiscordThreadChannel = { id: string; name?: string | null; parentId?: string | null; parent?: { id?: string; name?: string }; }; export type DiscordThreadStarter = { text: string; author: string; timestamp?: number; }; type DiscordThreadParentInfo = { id?: string; name?: string; type?: ChannelType; }; const DISCORD_THREAD_STARTER_CACHE = new Map(); function isDiscordThreadType(type: ChannelType | undefined): boolean { return ( type === ChannelType.PublicThread || type === ChannelType.PrivateThread || type === ChannelType.AnnouncementThread ); } export function resolveDiscordThreadChannel(params: { isGuildMessage: boolean; message: DiscordMessageEvent["message"]; channelInfo: import("./message-utils.js").DiscordChannelInfo | null; }): DiscordThreadChannel | null { if (!params.isGuildMessage) return null; const { message, channelInfo } = params; const channel = "channel" in message ? (message as { channel?: unknown }).channel : undefined; const isThreadChannel = channel && typeof channel === "object" && "isThread" in channel && typeof (channel as { isThread?: unknown }).isThread === "function" && (channel as { isThread: () => boolean }).isThread(); if (isThreadChannel) return channel as unknown as DiscordThreadChannel; if (!isDiscordThreadType(channelInfo?.type)) return null; return { id: message.channelId, name: channelInfo?.name ?? undefined, parentId: channelInfo?.parentId ?? undefined, parent: undefined, }; } export async function resolveDiscordThreadParentInfo(params: { client: Client; threadChannel: DiscordThreadChannel; channelInfo: import("./message-utils.js").DiscordChannelInfo | null; }): Promise { const { threadChannel, channelInfo, client } = params; const parentId = threadChannel.parentId ?? threadChannel.parent?.id ?? channelInfo?.parentId ?? undefined; if (!parentId) return {}; let parentName = threadChannel.parent?.name; const parentInfo = await resolveDiscordChannelInfo(client, parentId); parentName = parentName ?? parentInfo?.name; const parentType = parentInfo?.type; return { id: parentId, name: parentName, type: parentType }; } export async function resolveDiscordThreadStarter(params: { channel: DiscordThreadChannel; client: Client; parentId?: string; parentType?: ChannelType; resolveTimestampMs: (value?: string | null) => number | undefined; }): Promise { const cacheKey = params.channel.id; const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey); if (cached) return cached; try { const parentType = params.parentType; const isForumParent = parentType === ChannelType.GuildForum || parentType === ChannelType.GuildMedia; const messageChannelId = isForumParent ? params.channel.id : params.parentId; if (!messageChannelId) return null; const starter = (await params.client.rest.get( Routes.channelMessage(messageChannelId, params.channel.id), )) as { content?: string | null; embeds?: Array<{ description?: string | null }>; member?: { nick?: string | null; displayName?: string | null }; author?: { id?: string | null; username?: string | null; discriminator?: string | null; }; timestamp?: string | null; }; if (!starter) return null; const text = starter.content?.trim() ?? starter.embeds?.[0]?.description?.trim() ?? ""; if (!text) return null; const author = starter.member?.nick ?? starter.member?.displayName ?? (starter.author ? starter.author.discriminator && starter.author.discriminator !== "0" ? `${starter.author.username ?? "Unknown"}#${starter.author.discriminator}` : (starter.author.username ?? starter.author.id ?? "Unknown") : "Unknown"); const timestamp = params.resolveTimestampMs(starter.timestamp); const payload: DiscordThreadStarter = { text, author, timestamp: timestamp ?? undefined, }; DISCORD_THREAD_STARTER_CACHE.set(cacheKey, payload); return payload; } catch { return null; } } export function resolveDiscordReplyTarget(opts: { replyToMode: ReplyToMode; replyToId?: string; hasReplied: boolean; }): string | undefined { if (opts.replyToMode === "off") return undefined; const replyToId = opts.replyToId?.trim(); if (!replyToId) return undefined; if (opts.replyToMode === "all") return replyToId; return opts.hasReplied ? undefined : replyToId; } export function sanitizeDiscordThreadName(rawName: string, fallbackId: string): string { const cleanedName = rawName .replace(/<@!?\d+>/g, "") // user mentions .replace(/<@&\d+>/g, "") // role mentions .replace(/<#\d+>/g, "") // channel mentions .replace(/\s+/g, " ") .trim(); const baseSource = cleanedName || `Thread ${fallbackId}`; const base = truncateUtf16Safe(baseSource, 80); return truncateUtf16Safe(base, 100) || `Thread ${fallbackId}`; } type DiscordReplyDeliveryPlan = { deliverTarget: string; replyTarget: string; replyReference: ReturnType; }; export type DiscordAutoThreadContext = { createdThreadId: string; From: string; To: string; OriginatingTo: string; SessionKey: string; ParentSessionKey: string; }; export function resolveDiscordAutoThreadContext(params: { agentId: string; channel: string; messageChannelId: string; createdThreadId?: string | null; }): DiscordAutoThreadContext | null { const createdThreadId = String(params.createdThreadId ?? "").trim(); if (!createdThreadId) return null; const messageChannelId = params.messageChannelId.trim(); if (!messageChannelId) return null; const threadSessionKey = buildAgentSessionKey({ agentId: params.agentId, channel: params.channel, peer: { kind: "channel", id: createdThreadId }, }); const parentSessionKey = buildAgentSessionKey({ agentId: params.agentId, channel: params.channel, peer: { kind: "channel", id: messageChannelId }, }); return { createdThreadId, From: `${params.channel}:channel:${createdThreadId}`, To: `channel:${createdThreadId}`, OriginatingTo: `channel:${createdThreadId}`, SessionKey: threadSessionKey, ParentSessionKey: parentSessionKey, }; } export type DiscordAutoThreadReplyPlan = DiscordReplyDeliveryPlan & { createdThreadId?: string; autoThreadContext: DiscordAutoThreadContext | null; }; export async function resolveDiscordAutoThreadReplyPlan(params: { client: Client; message: DiscordMessageEvent["message"]; isGuildMessage: boolean; channelConfig?: DiscordChannelConfigResolved | null; threadChannel?: DiscordThreadChannel | null; baseText: string; combinedBody: string; replyToMode: ReplyToMode; agentId: string; channel: string; }): Promise { const originalReplyTarget = `channel:${params.message.channelId}`; const createdThreadId = await maybeCreateDiscordAutoThread({ client: params.client, message: params.message, isGuildMessage: params.isGuildMessage, channelConfig: params.channelConfig, threadChannel: params.threadChannel, baseText: params.baseText, combinedBody: params.combinedBody, }); const deliveryPlan = resolveDiscordReplyDeliveryPlan({ replyTarget: originalReplyTarget, replyToMode: params.replyToMode, messageId: params.message.id, threadChannel: params.threadChannel, createdThreadId, }); const autoThreadContext = params.isGuildMessage ? resolveDiscordAutoThreadContext({ agentId: params.agentId, channel: params.channel, messageChannelId: params.message.channelId, createdThreadId, }) : null; return { ...deliveryPlan, createdThreadId, autoThreadContext }; } export async function maybeCreateDiscordAutoThread(params: { client: Client; message: DiscordMessageEvent["message"]; isGuildMessage: boolean; channelConfig?: DiscordChannelConfigResolved | null; threadChannel?: DiscordThreadChannel | null; baseText: string; combinedBody: string; }): Promise { if (!params.isGuildMessage) return undefined; if (!params.channelConfig?.autoThread) return undefined; if (params.threadChannel) return undefined; try { const threadName = sanitizeDiscordThreadName( params.baseText || params.combinedBody || "Thread", params.message.id, ); const created = (await params.client.rest.post( `${Routes.channelMessage(params.message.channelId, params.message.id)}/threads`, { body: { name: threadName, auto_archive_duration: 60, }, }, )) as { id?: string }; const createdId = created?.id ? String(created.id) : ""; return createdId || undefined; } catch (err) { logVerbose( `discord: autoThread failed for ${params.message.channelId}/${params.message.id}: ${String(err)}`, ); return undefined; } } export function resolveDiscordReplyDeliveryPlan(params: { replyTarget: string; replyToMode: ReplyToMode; messageId: string; threadChannel?: DiscordThreadChannel | null; createdThreadId?: string | null; }): DiscordReplyDeliveryPlan { const originalReplyTarget = params.replyTarget; let deliverTarget = originalReplyTarget; let replyTarget = originalReplyTarget; if (params.createdThreadId) { deliverTarget = `channel:${params.createdThreadId}`; replyTarget = deliverTarget; } const allowReference = deliverTarget === originalReplyTarget; const replyReference = createReplyReferencePlanner({ replyToMode: allowReference ? params.replyToMode : "off", existingId: params.threadChannel ? params.messageId : undefined, startId: params.messageId, allowReference, }); return { deliverTarget, replyTarget, replyReference }; }