diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab17a67944..2a581edb9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai - Sandbox/Docker: apply custom bind mounts after workspace mounts and prioritize bind-source resolution on overlapping paths, so explicit workspace binds are no longer ignored. (#22669) Thanks @tasaankaeris. - Exec approvals/Forwarding: restore Discord text forwarding when component approvals are not configured, and carry request snapshots through resolve events so resolved notices still forward after cache misses/restarts. (#22988) Thanks @bubmiller. - Security/Elevated: match `tools.elevated.allowFrom` against sender identities only (not recipient `ctx.To`), closing a recipient-token bypass for `/elevated` authorization. (#11022) Thanks @coygeek. +- Webchat/Sessions: preserve external session routing metadata when internal `chat.send` turns run under `webchat`, so explicit channel-keyed sessions (for example Telegram) no longer get rewritten to `webchat` and misroute follow-up delivery. (#23258) Thanks @binary64. - Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake. - Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Group policy: harden `channels.*.groups.*.toolsBySender` matching by requiring explicit sender-key types (`id:`, `e164:`, `username:`, `name:`), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. This ships in the next npm release. Thanks @jiseoung for reporting. diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index aa442adb4eb..d73f2b1ad3a 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -1371,3 +1371,72 @@ describe("initSessionState stale threadId fallback", () => { expect(result.sessionEntry.lastThreadId).toBe(99); }); }); + +describe("initSessionState internal channel routing preservation", () => { + it("keeps persisted external lastChannel when OriginatingChannel is internal webchat", async () => { + const storePath = await createStorePath("preserve-external-channel-"); + const sessionKey = "agent:main:telegram:group:12345"; + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: "sess-1", + updatedAt: Date.now(), + lastChannel: "telegram", + lastTo: "group:12345", + deliveryContext: { + channel: "telegram", + to: "group:12345", + }, + }, + }); + const cfg = { session: { store: storePath } } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "internal follow-up", + SessionKey: sessionKey, + OriginatingChannel: "webchat", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.sessionEntry.lastChannel).toBe("telegram"); + expect(result.sessionEntry.deliveryContext?.channel).toBe("telegram"); + }); + + it("uses session key channel hint when first turn is internal webchat", async () => { + const storePath = await createStorePath("session-key-channel-hint-"); + const sessionKey = "agent:main:telegram:group:98765"; + const cfg = { session: { store: storePath } } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "hello", + SessionKey: sessionKey, + OriginatingChannel: "webchat", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.sessionEntry.lastChannel).toBe("telegram"); + expect(result.sessionEntry.deliveryContext?.channel).toBe("telegram"); + }); + + it("keeps webchat channel for webchat/main sessions", async () => { + const storePath = await createStorePath("preserve-webchat-main-"); + const cfg = { session: { store: storePath } } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "hello", + SessionKey: "agent:main:main", + OriginatingChannel: "webchat", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.sessionEntry.lastChannel).toBe("webchat"); + }); +}); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index f644f47aa34..d95cfa825b7 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -31,7 +31,13 @@ import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenanc import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { normalizeMainKey } from "../../routing/session-key.js"; +import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js"; +import { + INTERNAL_MESSAGE_CHANNEL, + isDeliverableMessageChannel, + normalizeMessageChannel, +} from "../../utils/message-channel.js"; import { resolveCommandAuthorization } from "../command-auth.js"; import type { MsgContext, TemplateContext } from "../templating.js"; import { normalizeInboundTextNewlines } from "./inbound-text.js"; @@ -39,6 +45,18 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; const log = createSubsystemLogger("session-init"); +function resolveSessionKeyChannelHint(sessionKey?: string): string | undefined { + const parsed = parseAgentSessionKey(sessionKey); + if (!parsed?.rest) { + return undefined; + } + const head = parsed.rest.split(":")[0]?.trim().toLowerCase(); + if (!head || head === "main" || head === "cron" || head === "subagent" || head === "acp") { + return undefined; + } + return normalizeMessageChannel(head); +} + export type SessionInitResult = { sessionCtx: TemplateContext; sessionEntry: SessionEntry; @@ -267,7 +285,28 @@ export async function initSessionState(params: { const baseEntry = !isNewSession && freshEntry ? entry : undefined; // Track the originating channel/to for announce routing (subagent announce-back). - const lastChannelRaw = (ctx.OriginatingChannel as string | undefined) || baseEntry?.lastChannel; + const originatingChannelRaw = ctx.OriginatingChannel as string | undefined; + const originatingChannel = normalizeMessageChannel(originatingChannelRaw); + const persistedChannel = normalizeMessageChannel(baseEntry?.lastChannel); + const sessionKeyChannelHint = resolveSessionKeyChannelHint(sessionKey); + let lastChannelRaw = originatingChannelRaw || baseEntry?.lastChannel; + // Internal webchat/system turns should not overwrite previously known external + // delivery routes (or explicit channel hints encoded in the session key). + if (originatingChannel === INTERNAL_MESSAGE_CHANNEL) { + if ( + persistedChannel && + persistedChannel !== INTERNAL_MESSAGE_CHANNEL && + isDeliverableMessageChannel(persistedChannel) + ) { + lastChannelRaw = persistedChannel; + } else if ( + sessionKeyChannelHint && + sessionKeyChannelHint !== INTERNAL_MESSAGE_CHANNEL && + isDeliverableMessageChannel(sessionKeyChannelHint) + ) { + lastChannelRaw = sessionKeyChannelHint; + } + } const lastToRaw = ctx.OriginatingTo || ctx.To || baseEntry?.lastTo; const lastAccountIdRaw = ctx.AccountId || baseEntry?.lastAccountId; // Only fall back to persisted threadId for thread sessions. Non-thread