From 0d9172d7613e2824d0519505e87f003124a01e01 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 03:40:35 +0000 Subject: [PATCH] fix: persist session origin metadata --- CHANGELOG.md | 1 + docs/concepts/session.md | 7 +++ extensions/matrix/src/matrix/monitor/index.ts | 1 + src/config/sessions.test.ts | 30 +++++++++++++ src/config/sessions/store.ts | 17 ++++++-- .../monitor/message-handler.process.ts | 1 + src/imessage/monitor/monitor-provider.ts | 1 + src/signal/monitor/event-handler.ts | 1 + src/slack/monitor/message-handler/dispatch.ts | 1 + src/telegram/bot-message-context.ts | 1 + src/web/auto-reply/monitor/last-route.ts | 3 ++ src/web/auto-reply/monitor/on-message.ts | 18 ++++++++ src/web/auto-reply/monitor/process-message.ts | 43 ++++++++++--------- 13 files changed, 102 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 295d4bd7e..0b85f83ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.clawd.bot - Plugins: add the bundled BlueBubbles channel plugin (disabled by default). - Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader. - Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. +- Sessions: persist origin metadata for last-route updates so DM/channel/group sessions keep explainers. (#1133) — thanks @adam91holt. ## 2026.1.17-5 diff --git a/docs/concepts/session.md b/docs/concepts/session.md index bd8c1f9a4..3608e0a33 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -122,3 +122,10 @@ Each session entry records where it came from (best-effort) in `origin`: - `from`/`to`: raw routing ids from the inbound envelope - `accountId`: provider account id (when multi-account) - `threadId`: thread/topic id when the channel supports it +The origin fields are populated for direct messages, channels, and groups. If a +connector only updates delivery routing (for example, to keep a DM main session +fresh), it should still provide inbound context so the session keeps its +explainer metadata. Extensions can do this by sending `ConversationLabel`, +`GroupSubject`, `GroupChannel`, `GroupSpace`, and `SenderName` in the inbound +context and calling `recordSessionMetaFromInbound` (or passing the same context +to `updateLastRoute`). diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index de2e3a592..82cb9f591 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -552,6 +552,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi channel: "matrix", to: `room:${roomId}`, accountId: route.accountId, + ctx: ctxPayload, }); } diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index f6b2bb217..e56d16a94 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -176,6 +176,36 @@ describe("sessions", () => { }); }); + it("updateLastRoute records origin + group metadata when ctx is provided", async () => { + const sessionKey = "agent:main:whatsapp:group:123@g.us"; + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-")); + const storePath = path.join(dir, "sessions.json"); + await fs.writeFile(storePath, "{}", "utf-8"); + + await updateLastRoute({ + storePath, + sessionKey, + deliveryContext: { + channel: "whatsapp", + to: "123@g.us", + }, + ctx: { + Provider: "whatsapp", + ChatType: "group", + GroupSubject: "Family", + From: "123@g.us", + }, + }); + + const store = loadSessionStore(storePath); + expect(store[sessionKey]?.subject).toBe("Family"); + expect(store[sessionKey]?.channel).toBe("whatsapp"); + expect(store[sessionKey]?.groupId).toBe("123@g.us"); + expect(store[sessionKey]?.origin?.label).toBe("Family id:123@g.us"); + expect(store[sessionKey]?.origin?.provider).toBe("whatsapp"); + expect(store[sessionKey]?.origin?.chatType).toBe("group"); + }); + it("updateSessionStore preserves concurrent additions", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-")); const storePath = path.join(dir, "sessions.json"); diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index c65f50f6e..971b90bf3 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -368,8 +368,10 @@ export async function updateLastRoute(params: { to?: string; accountId?: string; deliveryContext?: DeliveryContext; + ctx?: MsgContext; + groupResolution?: import("./types.js").GroupKeyResolution | null; }) { - const { storePath, sessionKey, channel, to, accountId } = params; + const { storePath, sessionKey, channel, to, accountId, ctx } = params; return await withSessionStoreLock(storePath, async () => { const store = loadSessionStore(storePath); const existing = store[sessionKey]; @@ -389,13 +391,22 @@ export async function updateLastRoute(params: { accountId: merged?.accountId, }, }); - const next = mergeSessionEntry(existing, { + const metaPatch = ctx + ? deriveSessionMetaPatch({ + ctx, + sessionKey, + existing, + groupResolution: params.groupResolution, + }) + : null; + const basePatch: Partial = { updatedAt: Math.max(existing?.updatedAt ?? 0, now), deliveryContext: normalized.deliveryContext, lastChannel: normalized.lastChannel, lastTo: normalized.lastTo, lastAccountId: normalized.lastAccountId, - }); + }; + const next = mergeSessionEntry(existing, metaPatch ? { ...basePatch, ...metaPatch } : basePatch); store[sessionKey] = next; await saveSessionStoreUnlocked(storePath, store); return next; diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 1fd298859..39d4f47d4 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -288,6 +288,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) to: `user:${author.id}`, accountId: route.accountId, }, + ctx: ctxPayload, }); } diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index ff2c05ec1..4a388d8cb 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -475,6 +475,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P to, accountId: route.accountId, }, + ctx: ctxPayload, }); } } diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 752e3d3a6..bfbd9bd72 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -164,6 +164,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { to: entry.senderRecipient, accountId: route.accountId, }, + ctx: ctxPayload, }); } diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index d50abacf5..de1b1f267 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -37,6 +37,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag to: `user:${message.user}`, accountId: route.accountId, }, + ctx: prepared.ctxPayload, }); } diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 364a32ec3..deecb7385 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -537,6 +537,7 @@ export const buildTelegramMessageContext = async ({ to: String(chatId), accountId: route.accountId, }, + ctx: ctxPayload, }); } diff --git a/src/web/auto-reply/monitor/last-route.ts b/src/web/auto-reply/monitor/last-route.ts index 6e52628d3..5359dbbcd 100644 --- a/src/web/auto-reply/monitor/last-route.ts +++ b/src/web/auto-reply/monitor/last-route.ts @@ -1,3 +1,4 @@ +import type { MsgContext } from "../../../auto-reply/templating.js"; import type { loadConfig } from "../../../config/config.js"; import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js"; import { formatError } from "../../session.js"; @@ -20,6 +21,7 @@ export function updateLastRouteInBackground(params: { channel: "whatsapp"; to: string; accountId?: string; + ctx?: MsgContext; warn: (obj: unknown, msg: string) => void; }) { const storePath = resolveStorePath(params.cfg.session?.store, { @@ -33,6 +35,7 @@ export function updateLastRouteInBackground(params: { to: params.to, accountId: params.accountId, }, + ctx: params.ctx, }).catch((err) => { params.warn( { diff --git a/src/web/auto-reply/monitor/on-message.ts b/src/web/auto-reply/monitor/on-message.ts index 78b06c73d..7e260d49e 100644 --- a/src/web/auto-reply/monitor/on-message.ts +++ b/src/web/auto-reply/monitor/on-message.ts @@ -1,3 +1,4 @@ +import type { MsgContext } from "../../../auto-reply/templating.js"; import type { getReplyFromConfig } from "../../../auto-reply/reply.js"; import type { loadConfig } from "../../../config/config.js"; import { logVerbose } from "../../../globals.js"; @@ -94,6 +95,22 @@ export function createWebOnMessageHandler(params: { } if (msg.chatType === "group") { + const metaCtx = { + From: msg.from, + To: msg.to, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: msg.chatType, + ConversationLabel: conversationId, + GroupSubject: msg.groupSubject, + SenderName: msg.senderName, + SenderId: msg.senderJid?.trim() || msg.senderE164, + SenderE164: msg.senderE164, + Provider: "whatsapp", + Surface: "whatsapp", + OriginatingChannel: "whatsapp", + OriginatingTo: conversationId, + } satisfies MsgContext; updateLastRouteInBackground({ cfg: params.cfg, backgroundTasks: params.backgroundTasks, @@ -102,6 +119,7 @@ export function createWebOnMessageHandler(params: { channel: "whatsapp", to: conversationId, accountId: route.accountId, + ctx: metaCtx, warn: params.replyLogger.warn.bind(params.replyLogger), }); diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index 3ad1b5bb0..d1b592a81 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -206,26 +206,15 @@ export async function processMessage(params: { whatsappInboundLog.debug(`Inbound body: ${elide(combinedBody, 400)}`); } - if (params.msg.chatType !== "group") { - const to = (() => { - if (params.msg.senderE164) return normalizeE164(params.msg.senderE164); - // In direct chats, `msg.from` is already the canonical conversation id. - if (params.msg.from.includes("@")) return jidToE164(params.msg.from); - return normalizeE164(params.msg.from); - })(); - if (to) { - updateLastRouteInBackground({ - cfg: params.cfg, - backgroundTasks: params.backgroundTasks, - storeAgentId: params.route.agentId, - sessionKey: params.route.mainSessionKey, - channel: "whatsapp", - to, - accountId: params.route.accountId, - warn: params.replyLogger.warn.bind(params.replyLogger), - }); - } - } + const dmRouteTarget = + params.msg.chatType !== "group" + ? (() => { + if (params.msg.senderE164) return normalizeE164(params.msg.senderE164); + // In direct chats, `msg.from` is already the canonical conversation id. + if (params.msg.from.includes("@")) return jidToE164(params.msg.from); + return normalizeE164(params.msg.from); + })() + : undefined; const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp"); let didLogHeartbeatStrip = false; @@ -285,6 +274,20 @@ export async function processMessage(params: { OriginatingTo: params.msg.from, }); + if (dmRouteTarget) { + updateLastRouteInBackground({ + cfg: params.cfg, + backgroundTasks: params.backgroundTasks, + storeAgentId: params.route.agentId, + sessionKey: params.route.mainSessionKey, + channel: "whatsapp", + to: dmRouteTarget, + accountId: params.route.accountId, + ctx: ctxPayload, + warn: params.replyLogger.warn.bind(params.replyLogger), + }); + } + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.route.agentId, });