diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 3317753ca..eef07acd9 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -26,6 +26,7 @@ import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply- import { deliverDiscordReply } from "./reply-delivery.js"; import { maybeCreateDiscordAutoThread, + resolveDiscordAutoThreadContext, resolveDiscordReplyDeliveryPlan, resolveDiscordThreadStarter, } from "./threading.js"; @@ -192,100 +193,85 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) peer: { kind: "channel", id: threadParentId }, }); } - } - const mediaPayload = buildDiscordMediaPayload(mediaList); - const discordTo = `channel:${message.channelId}`; - const threadKeys = resolveThreadSessionKeys({ - baseSessionKey, - threadId: threadChannel ? message.channelId : undefined, - parentSessionKey, - useSuffix: false, - }); - let ctxPayload = { - Body: combinedBody, - RawBody: baseText, - CommandBody: baseText, - From: isDirectMessage ? `discord:${author.id}` : `group:${message.channelId}`, - To: discordTo, - SessionKey: threadKeys.sessionKey, - AccountId: route.accountId, - ChatType: isDirectMessage ? "direct" : "group", - SenderName: data.member?.nickname ?? author.globalName ?? author.username, - SenderId: author.id, - SenderUsername: author.username, - SenderTag: formatDiscordUserTag(author), - GroupSubject: groupSubject, - GroupRoom: groupRoom, - GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined, - GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined, - Provider: "discord" as const, - Surface: "discord" as const, - WasMentioned: effectiveWasMentioned, - MessageSid: message.id, - ParentSessionKey: threadKeys.parentSessionKey, - ThreadStarterBody: threadStarterBody, - ThreadLabel: threadLabel, - Timestamp: resolveTimestampMs(message.timestamp), - ...mediaPayload, - CommandAuthorized: commandAuthorized, - CommandSource: "text" as const, - // Originating channel for reply routing. - OriginatingChannel: "discord" as const, - OriginatingTo: discordTo, - }; - let replyTarget = ctxPayload.To ?? undefined; - if (!replyTarget) { - runtime.error?.(danger("discord: missing reply target")); - return; - } - const createdThreadId = await maybeCreateDiscordAutoThread({ - client, - message, - isGuildMessage, - channelConfig, + } + const mediaPayload = buildDiscordMediaPayload(mediaList); + const threadKeys = resolveThreadSessionKeys({ + baseSessionKey, + threadId: threadChannel ? message.channelId : undefined, + parentSessionKey, + useSuffix: false, + }); + const inboundTarget = `channel:${message.channelId}`; + const createdThreadId = await maybeCreateDiscordAutoThread({ + client, + message, + isGuildMessage, + channelConfig, threadChannel, - baseText: baseText ?? "", - combinedBody, - }); - const replyPlan = resolveDiscordReplyDeliveryPlan({ - replyTarget, - replyToMode, - messageId: message.id, - threadChannel, - createdThreadId, - }); - const deliverTarget = replyPlan.deliverTarget; - replyTarget = replyPlan.replyTarget; - const replyReference = replyPlan.replyReference; + baseText: baseText ?? "", + combinedBody, + }); + const replyPlan = resolveDiscordReplyDeliveryPlan({ + replyTarget: inboundTarget, + replyToMode, + messageId: message.id, + threadChannel, + createdThreadId, + }); + const deliverTarget = replyPlan.deliverTarget; + const replyTarget = replyPlan.replyTarget; + const replyReference = replyPlan.replyReference; - // If autoThread created a new thread, ensure we also isolate session context to that thread. - if (createdThreadId && replyTarget === `channel:${createdThreadId}`) { - const threadSessionKey = buildAgentSessionKey({ - agentId: route.agentId, - channel: route.channel, - peer: { kind: "channel", id: createdThreadId }, - }); - const autoParentSessionKey = buildAgentSessionKey({ - agentId: route.agentId, - channel: route.channel, - peer: { kind: "channel", id: message.channelId }, - }); - const autoThreadKeys = resolveThreadSessionKeys({ - baseSessionKey: threadSessionKey, - threadId: createdThreadId, - parentSessionKey: autoParentSessionKey, - useSuffix: false, - }); + const autoThreadContext = isGuildMessage + ? resolveDiscordAutoThreadContext({ + agentId: route.agentId, + channel: route.channel, + messageChannelId: message.channelId, + createdThreadId, + }) + : null; - ctxPayload = { - ...ctxPayload, - From: `group:${createdThreadId}`, - To: `channel:${createdThreadId}`, - OriginatingTo: `channel:${createdThreadId}`, - SessionKey: autoThreadKeys.sessionKey, - ParentSessionKey: autoThreadKeys.parentSessionKey, - }; - } + const effectiveFrom = isDirectMessage + ? `discord:${author.id}` + : (autoThreadContext?.From ?? `group:${message.channelId}`); + const effectiveTo = autoThreadContext?.To ?? replyTarget; + if (!effectiveTo) { + runtime.error?.(danger("discord: missing reply target")); + return; + } + + const ctxPayload = { + Body: combinedBody, + RawBody: baseText, + CommandBody: baseText, + From: effectiveFrom, + To: effectiveTo, + SessionKey: autoThreadContext?.SessionKey ?? threadKeys.sessionKey, + AccountId: route.accountId, + ChatType: isDirectMessage ? "direct" : "group", + SenderName: data.member?.nickname ?? author.globalName ?? author.username, + SenderId: author.id, + SenderUsername: author.username, + SenderTag: formatDiscordUserTag(author), + GroupSubject: groupSubject, + GroupRoom: groupRoom, + GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined, + GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined, + Provider: "discord" as const, + Surface: "discord" as const, + WasMentioned: effectiveWasMentioned, + MessageSid: message.id, + ParentSessionKey: autoThreadContext?.ParentSessionKey ?? threadKeys.parentSessionKey, + ThreadStarterBody: threadStarterBody, + ThreadLabel: threadLabel, + Timestamp: resolveTimestampMs(message.timestamp), + ...mediaPayload, + CommandAuthorized: commandAuthorized, + CommandSource: "text" as const, + // Originating channel for reply routing. + OriginatingChannel: "discord" as const, + OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget, + }; if (isDirectMessage) { const sessionCfg = cfg.session; @@ -301,17 +287,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) }); } - if (shouldLogVerbose()) { - const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n"); - logVerbose( - `discord inbound: channel=${message.channelId} from=${ctxPayload.From} preview="${preview}"`, - ); - } + if (shouldLogVerbose()) { + const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n"); + logVerbose( + `discord inbound: channel=${message.channelId} deliver=${deliverTarget} from=${ctxPayload.From} preview="${preview}"`, + ); + } - let didSendReply = false; - const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - humanDelay: resolveHumanDelayConfig(cfg, route.agentId), + let didSendReply = false; + const typingChannelId = deliverTarget.startsWith("channel:") + ? deliverTarget.slice("channel:".length) + : message.channelId; + const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ + responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, + humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => { const replyToId = replyReference.use(); await deliverDiscordReply({ @@ -328,11 +317,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) didSendReply = true; replyReference.markSent(); }, - onError: (err, info) => { - runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`)); - }, - onReplyStart: () => sendTyping({ client, channelId: message.channelId }), - }); + onError: (err, info) => { + runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`)); + }, + onReplyStart: () => sendTyping({ client, channelId: typingChannelId }), + }); const { queuedFinal, counts } = await dispatchReplyFromConfig({ ctx: ctxPayload, diff --git a/src/discord/monitor/threading.test.ts b/src/discord/monitor/threading.test.ts new file mode 100644 index 000000000..35e8de72e --- /dev/null +++ b/src/discord/monitor/threading.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { buildAgentSessionKey } from "../../routing/resolve-route.js"; +import { resolveDiscordAutoThreadContext } from "./threading.js"; + +describe("resolveDiscordAutoThreadContext", () => { + it("returns null when no createdThreadId", () => { + expect( + resolveDiscordAutoThreadContext({ + agentId: "agent", + channel: "discord", + messageChannelId: "parent", + createdThreadId: undefined, + }), + ).toBeNull(); + }); + + it("re-keys session context to the created thread", () => { + const context = resolveDiscordAutoThreadContext({ + agentId: "agent", + channel: "discord", + messageChannelId: "parent", + createdThreadId: "thread", + }); + expect(context).not.toBeNull(); + expect(context?.To).toBe("channel:thread"); + expect(context?.From).toBe("group:thread"); + expect(context?.OriginatingTo).toBe("channel:thread"); + expect(context?.SessionKey).toBe( + buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "thread" }, + }), + ); + expect(context?.ParentSessionKey).toBe( + buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "parent" }, + }), + ); + }); +}); + diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index 9eaa569a4..f7d9f7de8 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -3,6 +3,7 @@ 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"; @@ -160,6 +161,47 @@ type DiscordReplyDeliveryPlan = { 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: `group:${createdThreadId}`, + To: `channel:${createdThreadId}`, + OriginatingTo: `channel:${createdThreadId}`, + SessionKey: threadSessionKey, + ParentSessionKey: parentSessionKey, + }; +} + export async function maybeCreateDiscordAutoThread(params: { client: Client; message: DiscordMessageEvent["message"];