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 { deliverDiscordReply } from "./reply-delivery.js";
|
||||||
import {
|
import {
|
||||||
maybeCreateDiscordAutoThread,
|
maybeCreateDiscordAutoThread,
|
||||||
|
resolveDiscordAutoThreadContext,
|
||||||
resolveDiscordReplyDeliveryPlan,
|
resolveDiscordReplyDeliveryPlan,
|
||||||
resolveDiscordThreadStarter,
|
resolveDiscordThreadStarter,
|
||||||
} from "./threading.js";
|
} from "./threading.js";
|
||||||
@@ -192,100 +193,85 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
peer: { kind: "channel", id: threadParentId },
|
peer: { kind: "channel", id: threadParentId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const mediaPayload = buildDiscordMediaPayload(mediaList);
|
const mediaPayload = buildDiscordMediaPayload(mediaList);
|
||||||
const discordTo = `channel:${message.channelId}`;
|
const threadKeys = resolveThreadSessionKeys({
|
||||||
const threadKeys = resolveThreadSessionKeys({
|
baseSessionKey,
|
||||||
baseSessionKey,
|
threadId: threadChannel ? message.channelId : undefined,
|
||||||
threadId: threadChannel ? message.channelId : undefined,
|
parentSessionKey,
|
||||||
parentSessionKey,
|
useSuffix: false,
|
||||||
useSuffix: false,
|
});
|
||||||
});
|
const inboundTarget = `channel:${message.channelId}`;
|
||||||
let ctxPayload = {
|
const createdThreadId = await maybeCreateDiscordAutoThread({
|
||||||
Body: combinedBody,
|
client,
|
||||||
RawBody: baseText,
|
message,
|
||||||
CommandBody: baseText,
|
isGuildMessage,
|
||||||
From: isDirectMessage ? `discord:${author.id}` : `group:${message.channelId}`,
|
channelConfig,
|
||||||
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,
|
|
||||||
threadChannel,
|
threadChannel,
|
||||||
baseText: baseText ?? "",
|
baseText: baseText ?? "",
|
||||||
combinedBody,
|
combinedBody,
|
||||||
});
|
});
|
||||||
const replyPlan = resolveDiscordReplyDeliveryPlan({
|
const replyPlan = resolveDiscordReplyDeliveryPlan({
|
||||||
replyTarget,
|
replyTarget: inboundTarget,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
threadChannel,
|
threadChannel,
|
||||||
createdThreadId,
|
createdThreadId,
|
||||||
});
|
});
|
||||||
const deliverTarget = replyPlan.deliverTarget;
|
const deliverTarget = replyPlan.deliverTarget;
|
||||||
replyTarget = replyPlan.replyTarget;
|
const replyTarget = replyPlan.replyTarget;
|
||||||
const replyReference = replyPlan.replyReference;
|
const replyReference = replyPlan.replyReference;
|
||||||
|
|
||||||
// If autoThread created a new thread, ensure we also isolate session context to that thread.
|
const autoThreadContext = isGuildMessage
|
||||||
if (createdThreadId && replyTarget === `channel:${createdThreadId}`) {
|
? resolveDiscordAutoThreadContext({
|
||||||
const threadSessionKey = buildAgentSessionKey({
|
agentId: route.agentId,
|
||||||
agentId: route.agentId,
|
channel: route.channel,
|
||||||
channel: route.channel,
|
messageChannelId: message.channelId,
|
||||||
peer: { kind: "channel", id: createdThreadId },
|
createdThreadId,
|
||||||
});
|
})
|
||||||
const autoParentSessionKey = buildAgentSessionKey({
|
: null;
|
||||||
agentId: route.agentId,
|
|
||||||
channel: route.channel,
|
|
||||||
peer: { kind: "channel", id: message.channelId },
|
|
||||||
});
|
|
||||||
const autoThreadKeys = resolveThreadSessionKeys({
|
|
||||||
baseSessionKey: threadSessionKey,
|
|
||||||
threadId: createdThreadId,
|
|
||||||
parentSessionKey: autoParentSessionKey,
|
|
||||||
useSuffix: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
ctxPayload = {
|
const effectiveFrom = isDirectMessage
|
||||||
...ctxPayload,
|
? `discord:${author.id}`
|
||||||
From: `group:${createdThreadId}`,
|
: (autoThreadContext?.From ?? `group:${message.channelId}`);
|
||||||
To: `channel:${createdThreadId}`,
|
const effectiveTo = autoThreadContext?.To ?? replyTarget;
|
||||||
OriginatingTo: `channel:${createdThreadId}`,
|
if (!effectiveTo) {
|
||||||
SessionKey: autoThreadKeys.sessionKey,
|
runtime.error?.(danger("discord: missing reply target"));
|
||||||
ParentSessionKey: autoThreadKeys.parentSessionKey,
|
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) {
|
if (isDirectMessage) {
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
@@ -301,17 +287,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldLogVerbose()) {
|
if (shouldLogVerbose()) {
|
||||||
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
|
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`discord inbound: channel=${message.channelId} from=${ctxPayload.From} preview="${preview}"`,
|
`discord inbound: channel=${message.channelId} deliver=${deliverTarget} from=${ctxPayload.From} preview="${preview}"`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let didSendReply = false;
|
let didSendReply = false;
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
const typingChannelId = deliverTarget.startsWith("channel:")
|
||||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
? deliverTarget.slice("channel:".length)
|
||||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
: message.channelId;
|
||||||
|
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||||
|
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||||
|
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
deliver: async (payload: ReplyPayload) => {
|
deliver: async (payload: ReplyPayload) => {
|
||||||
const replyToId = replyReference.use();
|
const replyToId = replyReference.use();
|
||||||
await deliverDiscordReply({
|
await deliverDiscordReply({
|
||||||
@@ -328,11 +317,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
replyReference.markSent();
|
replyReference.markSent();
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`));
|
runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`));
|
||||||
},
|
},
|
||||||
onReplyStart: () => sendTyping({ client, channelId: message.channelId }),
|
onReplyStart: () => sendTyping({ client, channelId: typingChannelId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
||||||
ctx: ctxPayload,
|
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 { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
|
||||||
import type { ReplyToMode } from "../../config/config.js";
|
import type { ReplyToMode } from "../../config/config.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
||||||
import { truncateUtf16Safe } from "../../utils.js";
|
import { truncateUtf16Safe } from "../../utils.js";
|
||||||
import type { DiscordChannelConfigResolved } from "./allow-list.js";
|
import type { DiscordChannelConfigResolved } from "./allow-list.js";
|
||||||
import type { DiscordMessageEvent } from "./listeners.js";
|
import type { DiscordMessageEvent } from "./listeners.js";
|
||||||
@@ -160,6 +161,47 @@ type DiscordReplyDeliveryPlan = {
|
|||||||
replyReference: ReturnType<typeof createReplyReferencePlanner>;
|
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: {
|
export async function maybeCreateDiscordAutoThread(params: {
|
||||||
client: Client;
|
client: Client;
|
||||||
message: DiscordMessageEvent["message"];
|
message: DiscordMessageEvent["message"];
|
||||||
|
|||||||
Reference in New Issue
Block a user