refactor(discord): clean autoThread context wiring (#856)
Build reply/session context once (no post-hoc ctx mutation) and type into the actual delivery target. Thanks @davidguttman. Co-authored-by: David Guttman <david@davidguttman.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
44
src/discord/monitor/threading.test.ts
Normal file
44
src/discord/monitor/threading.test.ts
Normal file
@@ -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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof createReplyReferencePlanner>;
|
||||
};
|
||||
|
||||
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"];
|
||||
|
||||
Reference in New Issue
Block a user