diff --git a/CHANGELOG.md b/CHANGELOG.md index ead5000b56b..80951a59ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent. - Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code. +- Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd. - Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras. - Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx. - Dev tooling: harden git `pre-commit` hook against option injection from malicious filenames (for example `--force`), preventing accidental staging of ignored files. Thanks @mrthankyou. diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 0dd7eb85f08..dc648e44280 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -770,7 +770,7 @@ export async function runEmbeddedAttempt( isCompacting: () => subscription.isCompacting(), abort: abortRun, }; - setActiveEmbeddedRun(params.sessionId, queueHandle); + setActiveEmbeddedRun(params.sessionId, queueHandle, params.sessionKey); let abortWarnTimer: NodeJS.Timeout | undefined; const isProbeSession = params.sessionId?.startsWith("probe-") ?? false; @@ -1113,7 +1113,7 @@ export async function runEmbeddedAttempt( `CRITICAL: unsubscribe failed, possible resource leak: runId=${params.runId} ${String(err)}`, ); } - clearActiveEmbeddedRun(params.sessionId, queueHandle); + clearActiveEmbeddedRun(params.sessionId, queueHandle, params.sessionKey); params.abortSignal?.removeEventListener?.("abort", onAbort); } diff --git a/src/agents/pi-embedded-runner/runs.ts b/src/agents/pi-embedded-runner/runs.ts index e0155874028..41dad4df582 100644 --- a/src/agents/pi-embedded-runner/runs.ts +++ b/src/agents/pi-embedded-runner/runs.ts @@ -115,11 +115,16 @@ function notifyEmbeddedRunEnded(sessionId: string) { } } -export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) { +export function setActiveEmbeddedRun( + sessionId: string, + handle: EmbeddedPiQueueHandle, + sessionKey?: string, +) { const wasActive = ACTIVE_EMBEDDED_RUNS.has(sessionId); ACTIVE_EMBEDDED_RUNS.set(sessionId, handle); logSessionStateChange({ sessionId, + sessionKey, state: "processing", reason: wasActive ? "run_replaced" : "run_started", }); @@ -128,10 +133,14 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH } } -export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) { +export function clearActiveEmbeddedRun( + sessionId: string, + handle: EmbeddedPiQueueHandle, + sessionKey?: string, +) { if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) { ACTIVE_EMBEDDED_RUNS.delete(sessionId); - logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" }); + logSessionStateChange({ sessionId, sessionKey, state: "idle", reason: "run_completed" }); if (!sessionId.startsWith("probe-")) { diag.debug(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); } diff --git a/src/discord/monitor/message-handler.inbound-contract.test.ts b/src/discord/monitor/message-handler.inbound-contract.test.ts index 06638531e35..da80e1c2ba8 100644 --- a/src/discord/monitor/message-handler.inbound-contract.test.ts +++ b/src/discord/monitor/message-handler.inbound-contract.test.ts @@ -59,6 +59,7 @@ describe("discord processDiscordMessage inbound contract", () => { attachments: [], // oxlint-disable-next-line typescript/no-explicit-any } as any, + messageChannelId: "c1", author: { id: "U1", username: "alice", @@ -131,6 +132,7 @@ describe("discord processDiscordMessage inbound contract", () => { timestamp: new Date().toISOString(), attachments: [], }, + messageChannelId: "c1", author: { id: "U1", username: "alice", diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 09b3559b835..d858f07502f 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -48,7 +48,11 @@ import { resolveDiscordSystemLocation, resolveTimestampMs, } from "./format.js"; -import { resolveDiscordChannelInfo, resolveDiscordMessageText } from "./message-utils.js"; +import { + resolveDiscordChannelInfo, + resolveDiscordMessageChannelId, + resolveDiscordMessageText, +} from "./message-utils.js"; import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js"; import { resolveDiscordSystemEvent } from "./system-events.js"; import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js"; @@ -67,6 +71,14 @@ export async function preflightDiscordMessage( if (!author) { return null; } + const messageChannelId = resolveDiscordMessageChannelId({ + message, + eventChannelId: params.data.channel_id, + }); + if (!messageChannelId) { + logVerbose(`discord: drop message ${message.id} (missing channel id)`); + return null; + } const allowBots = params.discordConfig?.allowBots ?? false; if (params.botUserId && author.id === params.botUserId) { @@ -102,11 +114,11 @@ export async function preflightDiscordMessage( } const isGuildMessage = Boolean(params.data.guild_id); - const channelInfo = await resolveDiscordChannelInfo(params.client, message.channelId); + const channelInfo = await resolveDiscordChannelInfo(params.client, messageChannelId); const isDirectMessage = channelInfo?.type === ChannelType.DM; const isGroupDm = channelInfo?.type === ChannelType.GroupDM; logDebug( - `[discord-preflight] channelId=${message.channelId} guild_id=${params.data.guild_id} channelType=${channelInfo?.type} isGuild=${isGuildMessage} isDM=${isDirectMessage} isGroupDm=${isGroupDm}`, + `[discord-preflight] channelId=${messageChannelId} guild_id=${params.data.guild_id} channelType=${channelInfo?.type} isGuild=${isGuildMessage} isDM=${isDirectMessage} isGroupDm=${isGroupDm}`, ); if (isGroupDm && !params.groupDmEnabled) { @@ -208,6 +220,7 @@ export async function preflightDiscordMessage( isGuildMessage, message, channelInfo, + messageChannelId, }); let earlyThreadParentId: string | undefined; let earlyThreadParentName: string | undefined; @@ -235,7 +248,7 @@ export async function preflightDiscordMessage( memberRoleIds, peer: { kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel", - id: isDirectMessage ? author.id : message.channelId, + id: isDirectMessage ? author.id : messageChannelId, }, // Pass parent peer for thread binding inheritance parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined, @@ -305,7 +318,7 @@ export async function preflightDiscordMessage( const channelConfig = isGuildMessage ? resolveDiscordChannelConfigWithFallback({ guildInfo, - channelId: message.channelId, + channelId: messageChannelId, channelName, channelSlug: threadChannelSlug, parentId: threadParentId ?? undefined, @@ -320,13 +333,13 @@ export async function preflightDiscordMessage( ? `allowed=${channelConfig.allowed} enabled=${channelConfig.enabled ?? "unset"} requireMention=${channelConfig.requireMention ?? "unset"} matchKey=${channelConfig.matchKey ?? "none"} matchSource=${channelConfig.matchSource ?? "none"} users=${channelConfig.users?.length ?? 0} roles=${channelConfig.roles?.length ?? 0} skills=${channelConfig.skills?.length ?? 0}` : "none"; logDebug( - `[discord-preflight] channelConfig=${channelConfigSummary} channelMatchMeta=${channelMatchMeta} channelId=${message.channelId}`, + `[discord-preflight] channelConfig=${channelConfigSummary} channelMatchMeta=${channelMatchMeta} channelId=${messageChannelId}`, ); } if (isGuildMessage && channelConfig?.enabled === false) { logDebug(`[discord-preflight] drop: channel disabled`); logVerbose( - `Blocked discord channel ${message.channelId} (channel disabled, ${channelMatchMeta})`, + `Blocked discord channel ${messageChannelId} (channel disabled, ${channelMatchMeta})`, ); return null; } @@ -335,7 +348,7 @@ export async function preflightDiscordMessage( isGroupDm && resolveGroupDmAllow({ channels: params.groupDmChannels, - channelId: message.channelId, + channelId: messageChannelId, channelName: displayChannelName, channelSlug: displayChannelSlug, }); @@ -363,7 +376,7 @@ export async function preflightDiscordMessage( ); } else { logVerbose( - `Blocked discord channel ${message.channelId} not in guild channel allowlist (groupPolicy: allowlist, ${channelMatchMeta})`, + `Blocked discord channel ${messageChannelId} not in guild channel allowlist (groupPolicy: allowlist, ${channelMatchMeta})`, ); } return null; @@ -372,13 +385,13 @@ export async function preflightDiscordMessage( if (isGuildMessage && channelConfig?.allowed === false) { logDebug(`[discord-preflight] drop: channelConfig.allowed===false`); logVerbose( - `Blocked discord channel ${message.channelId} not in guild channel allowlist (${channelMatchMeta})`, + `Blocked discord channel ${messageChannelId} not in guild channel allowlist (${channelMatchMeta})`, ); return null; } if (isGuildMessage) { logDebug(`[discord-preflight] pass: channel allowed`); - logVerbose(`discord: allow channel ${message.channelId} (${channelMatchMeta})`); + logVerbose(`discord: allow channel ${messageChannelId} (${channelMatchMeta})`); } const textForHistory = resolveDiscordMessageText(message, { @@ -467,7 +480,7 @@ export async function preflightDiscordMessage( ); if (shouldLogVerbose()) { logVerbose( - `discord: inbound id=${message.id} guild=${params.data.guild_id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`, + `discord: inbound id=${message.id} guild=${params.data.guild_id ?? "dm"} channel=${messageChannelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`, ); } @@ -542,14 +555,14 @@ export async function preflightDiscordMessage( logVerbose(`discord: drop guild message (mention required, botId=${botId})`); logger.info( { - channelId: message.channelId, + channelId: messageChannelId, reason: "no-mention", }, "discord: skipping guild message", ); recordPendingHistoryEntryIfEnabled({ historyMap: params.guildHistories, - historyKey: message.channelId, + historyKey: messageChannelId, limit: params.historyLimit, entry: historyEntry ?? null, }); @@ -567,14 +580,14 @@ export async function preflightDiscordMessage( isDirectMessage, isGroupDm, guild: params.data.guild ?? undefined, - channelName: channelName ?? message.channelId, + channelName: channelName ?? messageChannelId, }); const systemText = resolveDiscordSystemEvent(message, systemLocation); if (systemText) { logDebug(`[discord-preflight] drop: system event`); enqueueSystemEvent(systemText, { sessionKey: route.sessionKey, - contextKey: `discord:system:${message.channelId}:${message.id}`, + contextKey: `discord:system:${messageChannelId}:${message.id}`, }); return null; } @@ -603,6 +616,7 @@ export async function preflightDiscordMessage( data: params.data, client: params.client, message, + messageChannelId, author, sender, channelInfo, diff --git a/src/discord/monitor/message-handler.preflight.types.ts b/src/discord/monitor/message-handler.preflight.types.ts index c447eab580d..931bdd75bb5 100644 --- a/src/discord/monitor/message-handler.preflight.types.ts +++ b/src/discord/monitor/message-handler.preflight.types.ts @@ -34,6 +34,7 @@ export type DiscordMessagePreflightContext = { data: DiscordMessageEvent; client: Client; message: DiscordMessageEvent["message"]; + messageChannelId: string; author: User; sender: DiscordSenderIdentity; diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 5e26257f317..c8bd869f252 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -59,6 +59,7 @@ async function createBaseContext(overrides: Record = {}) { timestamp: new Date().toISOString(), attachments: [], }, + messageChannelId: "c1", author: { id: "U1", username: "alice", @@ -130,4 +131,25 @@ describe("processDiscordMessage ack reactions", () => { expect(reactMessageDiscord).toHaveBeenCalledWith("c1", "m1", "👀", { rest: {} }); }); + + it("uses preflight-resolved messageChannelId when message.channelId is missing", async () => { + const ctx = await createBaseContext({ + message: { + id: "m1", + timestamp: new Date().toISOString(), + attachments: [], + }, + messageChannelId: "fallback-channel", + shouldRequireMention: true, + effectiveWasMentioned: true, + sender: { label: "user" }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(reactMessageDiscord).toHaveBeenCalledWith("fallback-channel", "m1", "👀", { + rest: {}, + }); + }); }); diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 04e45af24f7..8e936068c28 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -59,6 +59,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) client, channelInfo, channelName, + messageChannelId, isGuildMessage, isDirectMessage, isGroupDm, @@ -108,12 +109,12 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) }), ); const ackReactionPromise = shouldAckReaction() - ? reactMessageDiscord(message.channelId, message.id, ackReaction, { + ? reactMessageDiscord(messageChannelId, message.id, ackReaction, { rest: client.rest, }).then( () => true, (err) => { - logVerbose(`discord react failed for channel ${message.channelId}: ${String(err)}`); + logVerbose(`discord react failed for channel ${messageChannelId}: ${String(err)}`); return false; }, ) @@ -123,8 +124,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ? buildDirectLabel(author) : buildGuildLabel({ guild: data.guild ?? undefined, - channelName: channelName ?? message.channelId, - channelId: message.channelId, + channelName: channelName ?? messageChannelId, + channelId: messageChannelId, }); const senderLabel = sender.label; const isForumParent = @@ -184,7 +185,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) if (shouldIncludeChannelHistory) { combinedBody = buildPendingHistoryContextFromMap({ historyMap: guildHistories, - historyKey: message.channelId, + historyKey: messageChannelId, limit: historyLimit, currentMessage: combinedBody, formatEntry: (entry) => @@ -192,7 +193,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) channel: "Discord", from: fromLabel, timestamp: entry.timestamp, - body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`, + body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${messageChannelId}]`, chatType: "channel", senderLabel: entry.sender, envelope: envelopeOptions, @@ -237,13 +238,14 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const mediaPayload = buildDiscordMediaPayload(mediaList); const threadKeys = resolveThreadSessionKeys({ baseSessionKey, - threadId: threadChannel ? message.channelId : undefined, + threadId: threadChannel ? messageChannelId : undefined, parentSessionKey, useSuffix: false, }); const replyPlan = await resolveDiscordAutoThreadReplyPlan({ client, message, + messageChannelId, isGuildMessage, channelConfig, threadChannel, @@ -260,7 +262,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const effectiveFrom = isDirectMessage ? `discord:${author.id}` - : (autoThreadContext?.From ?? `discord:channel:${message.channelId}`); + : (autoThreadContext?.From ?? `discord:channel:${messageChannelId}`); const effectiveTo = autoThreadContext?.To ?? replyTarget; if (!effectiveTo) { runtime.error?.(danger("discord: missing reply target")); @@ -269,7 +271,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const inboundHistory = shouldIncludeChannelHistory && historyLimit > 0 - ? (guildHistories.get(message.channelId) ?? []).map((entry) => ({ + ? (guildHistories.get(messageChannelId) ?? []).map((entry) => ({ sender: entry.sender, body: entry.body, timestamp: entry.timestamp, @@ -337,13 +339,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) if (shouldLogVerbose()) { const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n"); logVerbose( - `discord inbound: channel=${message.channelId} deliver=${deliverTarget} from=${ctxPayload.From} preview="${preview}"`, + `discord inbound: channel=${messageChannelId} deliver=${deliverTarget} from=${ctxPayload.From} preview="${preview}"`, ); } const typingChannelId = deliverTarget.startsWith("channel:") ? deliverTarget.slice("channel:".length) - : message.channelId; + : messageChannelId; const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ cfg, @@ -412,7 +414,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) if (isGuildMessage) { clearHistoryEntriesIfEnabled({ historyMap: guildHistories, - historyKey: message.channelId, + historyKey: messageChannelId, limit: historyLimit, }); } @@ -429,7 +431,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ackReactionPromise, ackReactionValue: ackReaction, remove: async () => { - await removeReactionDiscord(message.channelId, message.id, ackReaction, { + await removeReactionDiscord(messageChannelId, message.id, ackReaction, { rest: client.rest, }); }, @@ -437,7 +439,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) logAckFailure({ log: logVerbose, channel: "discord", - target: `${message.channelId}/${message.id}`, + target: `${messageChannelId}/${message.id}`, error: err, }); }, @@ -445,7 +447,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) if (isGuildMessage) { clearHistoryEntriesIfEnabled({ historyMap: guildHistories, - historyKey: message.channelId, + historyKey: messageChannelId, limit: historyLimit, }); } diff --git a/src/discord/monitor/message-handler.ts b/src/discord/monitor/message-handler.ts index ad6f4803403..bfed7c16afd 100644 --- a/src/discord/monitor/message-handler.ts +++ b/src/discord/monitor/message-handler.ts @@ -9,7 +9,7 @@ import { import { danger } from "../../globals.js"; import { preflightDiscordMessage } from "./message-handler.preflight.js"; import { processDiscordMessage } from "./message-handler.process.js"; -import { resolveDiscordMessageText } from "./message-utils.js"; +import { resolveDiscordMessageChannelId, resolveDiscordMessageText } from "./message-utils.js"; type DiscordMessageHandlerParams = Omit< DiscordMessagePreflightParams, @@ -31,7 +31,10 @@ export function createDiscordMessageHandler( if (!message || !authorId) { return null; } - const channelId = message.channelId; + const channelId = resolveDiscordMessageChannelId({ + message, + eventChannelId: entry.data.channel_id, + }); if (!channelId) { return null; } diff --git a/src/discord/monitor/message-utils.test.ts b/src/discord/monitor/message-utils.test.ts new file mode 100644 index 00000000000..a6221eb04c3 --- /dev/null +++ b/src/discord/monitor/message-utils.test.ts @@ -0,0 +1,38 @@ +import type { Message } from "@buape/carbon"; +import { describe, expect, it } from "vitest"; +import { resolveDiscordMessageChannelId } from "./message-utils.js"; + +function asMessage(payload: Record): Message { + return payload as unknown as Message; +} + +describe("resolveDiscordMessageChannelId", () => { + it("uses message.channelId when present", () => { + const channelId = resolveDiscordMessageChannelId({ + message: asMessage({ channelId: " 123 " }), + }); + expect(channelId).toBe("123"); + }); + + it("falls back to message.channel_id", () => { + const channelId = resolveDiscordMessageChannelId({ + message: asMessage({ channel_id: " 234 " }), + }); + expect(channelId).toBe("234"); + }); + + it("falls back to message.rawData.channel_id", () => { + const channelId = resolveDiscordMessageChannelId({ + message: asMessage({ rawData: { channel_id: "456" } }), + }); + expect(channelId).toBe("456"); + }); + + it("falls back to eventChannelId and coerces numeric values", () => { + const channelId = resolveDiscordMessageChannelId({ + message: asMessage({}), + eventChannelId: 789, + }); + expect(channelId).toBe("789"); + }); +}); diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index f3968312415..f7f5f0d86e8 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -18,6 +18,11 @@ export type DiscordChannelInfo = { ownerId?: string; }; +type DiscordMessageWithChannelId = Message & { + channel_id?: unknown; + rawData?: { channel_id?: unknown }; +}; + type DiscordSnapshotAuthor = { id?: string | null; username?: string | null; @@ -48,6 +53,29 @@ export function __resetDiscordChannelInfoCacheForTest() { DISCORD_CHANNEL_INFO_CACHE.clear(); } +function normalizeDiscordChannelId(value: unknown): string { + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number" || typeof value === "bigint") { + return String(value).trim(); + } + return ""; +} + +export function resolveDiscordMessageChannelId(params: { + message: Message; + eventChannelId?: string | number | null; +}): string { + const message = params.message as DiscordMessageWithChannelId; + return ( + normalizeDiscordChannelId(message.channelId) || + normalizeDiscordChannelId(message.channel_id) || + normalizeDiscordChannelId(message.rawData?.channel_id) || + normalizeDiscordChannelId(params.eventChannelId) + ); +} + export async function resolveDiscordChannelInfo( client: Client, channelId: string, diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index 41c4ab5e0df..718f1d11daf 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -7,7 +7,7 @@ import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-refere import { logVerbose } from "../../globals.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { truncateUtf16Safe } from "../../utils.js"; -import { resolveDiscordChannelInfo } from "./message-utils.js"; +import { resolveDiscordChannelInfo, resolveDiscordMessageChannelId } from "./message-utils.js"; export type DiscordThreadChannel = { id: string; @@ -89,6 +89,7 @@ export function resolveDiscordThreadChannel(params: { isGuildMessage: boolean; message: DiscordMessageEvent["message"]; channelInfo: import("./message-utils.js").DiscordChannelInfo | null; + messageChannelId?: string; }): DiscordThreadChannel | null { if (!params.isGuildMessage) { return null; @@ -107,8 +108,16 @@ export function resolveDiscordThreadChannel(params: { if (!isDiscordThreadType(channelInfo?.type)) { return null; } + const messageChannelId = + params.messageChannelId || + resolveDiscordMessageChannelId({ + message, + }); + if (!messageChannelId) { + return null; + } return { - id: message.channelId, + id: messageChannelId, name: channelInfo?.name ?? undefined, parentId: channelInfo?.parentId ?? undefined, parent: undefined, @@ -285,6 +294,7 @@ export type DiscordAutoThreadReplyPlan = DiscordReplyDeliveryPlan & { export async function resolveDiscordAutoThreadReplyPlan(params: { client: Client; message: DiscordMessageEvent["message"]; + messageChannelId?: string; isGuildMessage: boolean; channelConfig?: DiscordChannelConfigResolved | null; threadChannel?: DiscordThreadChannel | null; @@ -294,12 +304,19 @@ export async function resolveDiscordAutoThreadReplyPlan(params: { agentId: string; channel: string; }): Promise { + const messageChannelId = ( + params.messageChannelId || + resolveDiscordMessageChannelId({ + message: params.message, + }) + ).trim(); // Prefer the resolved thread channel ID when available so replies stay in-thread. - const targetChannelId = params.threadChannel?.id ?? params.message.channelId; + const targetChannelId = params.threadChannel?.id ?? (messageChannelId || "unknown"); const originalReplyTarget = `channel:${targetChannelId}`; const createdThreadId = await maybeCreateDiscordAutoThread({ client: params.client, message: params.message, + messageChannelId: messageChannelId || undefined, isGuildMessage: params.isGuildMessage, channelConfig: params.channelConfig, threadChannel: params.threadChannel, @@ -317,7 +334,7 @@ export async function resolveDiscordAutoThreadReplyPlan(params: { ? resolveDiscordAutoThreadContext({ agentId: params.agentId, channel: params.channel, - messageChannelId: params.message.channelId, + messageChannelId, createdThreadId, }) : null; @@ -327,6 +344,7 @@ export async function resolveDiscordAutoThreadReplyPlan(params: { export async function maybeCreateDiscordAutoThread(params: { client: Client; message: DiscordMessageEvent["message"]; + messageChannelId?: string; isGuildMessage: boolean; channelConfig?: DiscordChannelConfigResolved | null; threadChannel?: DiscordThreadChannel | null; @@ -342,13 +360,22 @@ export async function maybeCreateDiscordAutoThread(params: { if (params.threadChannel) { return undefined; } + const messageChannelId = ( + params.messageChannelId || + resolveDiscordMessageChannelId({ + message: params.message, + }) + ).trim(); + if (!messageChannelId) { + 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`, + `${Routes.channelMessage(messageChannelId, params.message.id)}/threads`, { body: { name: threadName, @@ -360,18 +387,18 @@ export async function maybeCreateDiscordAutoThread(params: { return createdId || undefined; } catch (err) { logVerbose( - `discord: autoThread creation failed for ${params.message.channelId}/${params.message.id}: ${String(err)}`, + `discord: autoThread creation failed for ${messageChannelId}/${params.message.id}: ${String(err)}`, ); // Race condition: another agent may have already created a thread on this // message. Re-fetch the message to check for an existing thread. try { const msg = (await params.client.rest.get( - Routes.channelMessage(params.message.channelId, params.message.id), + Routes.channelMessage(messageChannelId, params.message.id), )) as { thread?: { id?: string } }; const existingThreadId = msg?.thread?.id ? String(msg.thread.id) : ""; if (existingThreadId) { logVerbose( - `discord: autoThread reusing existing thread ${existingThreadId} on ${params.message.channelId}/${params.message.id}`, + `discord: autoThread reusing existing thread ${existingThreadId} on ${messageChannelId}/${params.message.id}`, ); return existingThreadId; } diff --git a/src/logging/diagnostic-session-state.ts b/src/logging/diagnostic-session-state.ts index c060866ff92..aa5224550b8 100644 --- a/src/logging/diagnostic-session-state.ts +++ b/src/logging/diagnostic-session-state.ts @@ -56,10 +56,20 @@ function resolveSessionKey({ sessionKey, sessionId }: SessionRef) { return sessionKey ?? sessionId ?? "unknown"; } +function findStateBySessionId(sessionId: string): SessionState | undefined { + for (const state of diagnosticSessionStates.values()) { + if (state.sessionId === sessionId) { + return state; + } + } + return undefined; +} + export function getDiagnosticSessionState(ref: SessionRef): SessionState { pruneDiagnosticSessionStates(); const key = resolveSessionKey(ref); - const existing = diagnosticSessionStates.get(key); + const existing = + diagnosticSessionStates.get(key) ?? (ref.sessionId && findStateBySessionId(ref.sessionId)); if (existing) { if (ref.sessionId) { existing.sessionId = ref.sessionId; diff --git a/src/logging/diagnostic.test.ts b/src/logging/diagnostic.test.ts index 10c666432c1..1648b244b64 100644 --- a/src/logging/diagnostic.test.ts +++ b/src/logging/diagnostic.test.ts @@ -34,6 +34,18 @@ describe("diagnostic session state pruning", () => { expect(getDiagnosticSessionStateCountForTest()).toBe(2000); }); + + it("reuses keyed session state when later looked up by sessionId", () => { + const keyed = getDiagnosticSessionState({ + sessionId: "s1", + sessionKey: "agent:main:discord:channel:c1", + }); + const bySessionId = getDiagnosticSessionState({ sessionId: "s1" }); + + expect(bySessionId).toBe(keyed); + expect(bySessionId.sessionKey).toBe("agent:main:discord:channel:c1"); + expect(getDiagnosticSessionStateCountForTest()).toBe(1); + }); }); describe("logger import side effects", () => { diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 07d4bb79f4e..5cf2f3447d5 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -141,6 +141,17 @@ describe("resolveAgentRoute", () => { expect(route.matchedBy).toBe("binding.peer"); }); + test("coerces numeric peer ids to stable session keys", () => { + const cfg: OpenClawConfig = {}; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "default", + peer: { kind: "channel", id: 1468834856187203680n as unknown as string }, + }); + expect(route.sessionKey).toBe("agent:main:discord:channel:1468834856187203680"); + }); + test("guild binding wins over account binding when peer not bound", () => { const cfg: OpenClawConfig = { bindings: [ diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 5cfa2bc6417..578a3ca8f8a 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -61,8 +61,14 @@ function normalizeToken(value: string | undefined | null): string { return (value ?? "").trim().toLowerCase(); } -function normalizeId(value: string | undefined | null): string { - return (value ?? "").trim(); +function normalizeId(value: unknown): string { + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number" || typeof value === "bigint") { + return String(value).trim(); + } + return ""; } function normalizeAccountId(value: string | undefined | null): string {