diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 6b2839f58..cd2a9b585 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -366,6 +366,72 @@ export function sanitizeDiscordThreadName( return truncateUtf16Safe(base, 100) || `Thread ${fallbackId}`; } +type DiscordReplyDeliveryPlan = { + deliverTarget: string; + replyTarget: string; + replyReference: ReturnType; +}; + +async function maybeCreateDiscordAutoThread(params: { + client: Client; + message: DiscordMessageEvent["message"]; + isGuildMessage: boolean; + channelConfig?: DiscordChannelConfigResolved; + 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; + } +} + +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 }; +} + function summarizeAllowList(list?: Array) { if (!list || list.length === 0) return "any"; const sample = list.slice(0, 4).map((entry) => String(entry)); @@ -1205,45 +1271,25 @@ export function createDiscordMessageHandler(params: { runtime.error?.(danger("discord: missing reply target")); return; } - const originalReplyTarget = replyTarget; - - let deliverTarget = replyTarget; - if (isGuildMessage && channelConfig?.autoThread && !threadChannel) { - try { - const threadName = sanitizeDiscordThreadName( - baseText || combinedBody || "Thread", - message.id, - ); - - const created = (await client.rest.post( - `${Routes.channelMessage(message.channelId, message.id)}/threads`, - { - body: { - name: threadName, - auto_archive_duration: 60, - }, - }, - )) as { id?: string }; - - const createdId = created?.id ? String(created.id) : ""; - if (createdId) { - deliverTarget = `channel:${createdId}`; - replyTarget = deliverTarget; - } - } catch (err) { - logVerbose( - `discord: autoThread failed for ${message.channelId}/${message.id}: ${String(err)}`, - ); - } - } - - const replyReference = createReplyReferencePlanner({ - replyToMode: - deliverTarget !== originalReplyTarget ? "off" : replyToMode, - existingId: threadChannel ? message.id : undefined, - startId: message.id, - allowReference: deliverTarget === originalReplyTarget, + 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; if (isDirectMessage) { const sessionCfg = cfg.session; diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 99addb00e..d21224efa 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -1136,11 +1136,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { // Shared mutable ref for tracking if a reply was sent (used by both // auto-reply path and tool path for "first" threading mode). const hasRepliedRef = { value: false }; - const replyReference = createReplyReferencePlanner({ + const replyPlan = createSlackReplyDeliveryPlan({ replyToMode, - existingId: incomingThreadTs, - startId: messageTs, - hasReplied: hasRepliedRef.value, + incomingThreadTs, + messageTs, + hasRepliedRef, }); const onReplyStart = async () => { didSetStatus = true; @@ -1153,11 +1153,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { let didSendReply = false; const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ - responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId) - .responsePrefix, - humanDelay: resolveHumanDelayConfig(cfg, route.agentId), + responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId) + .responsePrefix, + humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload) => { - const replyThreadTs = replyReference.use(); + const replyThreadTs = replyPlan.nextThreadTs(); await deliverReplies({ replies: [payload], target: replyTarget, @@ -1168,8 +1168,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { replyThreadTs, }); didSendReply = true; - replyReference.markSent(); - hasRepliedRef.value = replyReference.hasReplied(); + replyPlan.markSent(); }, onError: (err, info) => { runtime.error?.( @@ -2074,13 +2073,53 @@ export function resolveSlackThreadTs(params: { messageTs: string | undefined; hasReplied: boolean; }): string | undefined { - const planner = createReplyReferencePlanner({ + const planner = createSlackReplyReferencePlanner({ + replyToMode: params.replyToMode, + incomingThreadTs: params.incomingThreadTs, + messageTs: params.messageTs, + hasReplied: params.hasReplied, + }); + return planner.use(); +} + +type SlackReplyDeliveryPlan = { + nextThreadTs: () => string | undefined; + markSent: () => void; +}; + +function createSlackReplyReferencePlanner(params: { + replyToMode: "off" | "first" | "all"; + incomingThreadTs: string | undefined; + messageTs: string | undefined; + hasReplied?: boolean; +}) { + return createReplyReferencePlanner({ replyToMode: params.replyToMode, existingId: params.incomingThreadTs, startId: params.messageTs, hasReplied: params.hasReplied, }); - return planner.use(); +} + +function createSlackReplyDeliveryPlan(params: { + replyToMode: "off" | "first" | "all"; + incomingThreadTs: string | undefined; + messageTs: string | undefined; + hasRepliedRef: { value: boolean }; +}): SlackReplyDeliveryPlan { + const replyReference = createSlackReplyReferencePlanner({ + replyToMode: params.replyToMode, + incomingThreadTs: params.incomingThreadTs, + messageTs: params.messageTs, + hasReplied: params.hasRepliedRef.value, + }); + return { + nextThreadTs: () => replyReference.use(), + markSent: () => { + replyReference.markSent(); + params.hasRepliedRef.value = replyReference.hasReplied(); + }, + }; } async function deliverSlackSlashReplies(params: {