From 4b6cdd1d3cb5e8f96c418e366e553185daeb5b0e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 11:57:04 +0000 Subject: [PATCH] fix: normalize session keys and outbound mirroring --- CHANGELOG.md | 2 + docs/refactor/outbound-session-mirroring.md | 72 ++ src/agents/agent-scope.ts | 3 +- ...-undefined-sessionkey-is-undefined.test.ts | 2 +- ...dded-runner.resolvesessionagentids.test.ts | 2 +- src/agents/sandbox-explain.test.ts | 2 +- src/agents/tools/message-tool.test.ts | 51 +- src/agents/tools/message-tool.ts | 29 - src/auto-reply/reply.raw-body.test.ts | 4 +- src/auto-reply/reply/abort.test.ts | 2 +- src/auto-reply/reply/commands.test.ts | 6 +- .../model-selection.inherit-parent.test.ts | 8 +- src/auto-reply/reply/session-resets.test.ts | 2 +- src/auto-reply/reply/session.test.ts | 26 +- src/commands/doctor-state-migrations.test.ts | 23 +- src/config/sessions/group.ts | 2 +- src/config/sessions/session-key.ts | 2 +- .../message-handler.inbound-contract.test.ts | 4 +- src/gateway/server-methods/send.test.ts | 31 + src/gateway/server-methods/send.ts | 45 +- .../message-action-runner.threading.test.ts | 92 ++ src/infra/outbound/message-action-runner.ts | 78 +- src/infra/outbound/message.ts | 2 + src/infra/outbound/outbound-send-service.ts | 16 + src/infra/outbound/outbound-session.test.ts | 105 +++ src/infra/outbound/outbound-session.ts | 834 ++++++++++++++++++ src/infra/state-migrations.ts | 18 +- src/routing/session-key.ts | 22 +- ...p-level-replies-replytomode-is-all.test.ts | 8 +- src/slack/monitor/context.test.ts | 2 +- .../prepare.sender-prefix.test.ts | 2 +- src/slack/monitor/slash.ts | 3 +- src/web/auto-reply/session-snapshot.test.ts | 2 +- 33 files changed, 1357 insertions(+), 145 deletions(-) create mode 100644 docs/refactor/outbound-session-mirroring.md create mode 100644 src/infra/outbound/message-action-runner.threading.test.ts create mode 100644 src/infra/outbound/outbound-session.test.ts create mode 100644 src/infra/outbound/outbound-session.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a4ba70f..ba39aabf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ Docs: https://docs.clawd.bot ### Fixes - Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518) - Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete. +- Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520) +- Sessions: normalize session key casing to lowercase for consistent routing. - Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest. - Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu. - Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops). diff --git a/docs/refactor/outbound-session-mirroring.md b/docs/refactor/outbound-session-mirroring.md new file mode 100644 index 000000000..43361d407 --- /dev/null +++ b/docs/refactor/outbound-session-mirroring.md @@ -0,0 +1,72 @@ +--- +title: Outbound Session Mirroring Refactor (Issue #1520) +description: Track outbound session mirroring refactor notes, decisions, tests, and open items. +--- + +# Outbound Session Mirroring Refactor (Issue #1520) + +## Status +- In progress. +- Core + plugin channel routing updated for outbound mirroring. +- Gateway send now derives target session when sessionKey is omitted. + +## Context +Outbound sends were mirrored into the *current* agent session (tool session key) rather than the target channel session. Inbound routing uses channel/peer session keys, so outbound responses landed in the wrong session and first-contact targets often lacked session entries. + +## Goals +- Mirror outbound messages into the target channel session key. +- Create session entries on outbound when missing. +- Keep thread/topic scoping aligned with inbound session keys. +- Cover core channels plus bundled extensions. + +## Implementation Summary +- New outbound session routing helper: + - `src/infra/outbound/outbound-session.ts` + - `resolveOutboundSessionRoute` builds target sessionKey using `buildAgentSessionKey` (dmScope + identityLinks). + - `ensureOutboundSessionEntry` writes minimal `MsgContext` via `recordSessionMetaFromInbound`. +- `runMessageAction` (send) derives target sessionKey and passes it to `executeSendAction` for mirroring. +- `message-tool` no longer mirrors directly; it only resolves agentId from the current session key. +- Plugin send path mirrors via `appendAssistantMessageToSessionTranscript` using the derived sessionKey. +- Gateway send derives a target session key when none is provided (default agent), and ensures a session entry. + +## Thread/Topic Handling +- Slack: replyTo/threadId -> `resolveThreadSessionKeys` (suffix). +- Discord: threadId/replyTo -> `resolveThreadSessionKeys` with `useSuffix=false` to match inbound (thread channel id already scopes session). +- Telegram: topic IDs map to `chatId:topic:` via `buildTelegramGroupPeerId`. + +## Extensions Covered +- Matrix, MS Teams, Mattermost, BlueBubbles, Nextcloud Talk, Zalo, Zalo Personal, Nostr, Tlon. +- Notes: + - Mattermost targets now strip `@` for DM session key routing. + - Zalo Personal uses DM peer kind for 1:1 targets (group only when `group:` is present). + +## Decisions +- **Gateway send session derivation**: if `sessionKey` is provided, use it. If omitted, derive a sessionKey from target + default agent and mirror there. +- **Session entry creation**: always use `recordSessionMetaFromInbound` with `Provider/From/To/ChatType/AccountId/Originating*` aligned to inbound formats. +- **Target normalization**: outbound routing uses resolved targets (post `resolveChannelTarget`) when available. +- **Session key casing**: canonicalize session keys to lowercase on write and during migrations. + +## Tests Added/Updated +- `src/infra/outbound/outbound-session.test.ts` + - Slack thread session key. + - Telegram topic session key. + - dmScope identityLinks with Discord. +- `src/agents/tools/message-tool.test.ts` + - Derives agentId from session key (no sessionKey passed through). +- `src/gateway/server-methods/send.test.ts` + - Derives session key when omitted and creates session entry. + +## Open Items / Follow-ups +- Voice-call plugin uses custom `voice:` session keys. Outbound mapping is not standardized here; if message-tool should support voice-call sends, add explicit mapping. +- Confirm if any external plugin uses non-standard `From/To` formats beyond the bundled set. + +## Files Touched +- `src/infra/outbound/outbound-session.ts` +- `src/infra/outbound/outbound-send-service.ts` +- `src/infra/outbound/message-action-runner.ts` +- `src/agents/tools/message-tool.ts` +- `src/gateway/server-methods/send.ts` +- Tests in: + - `src/infra/outbound/outbound-session.test.ts` + - `src/agents/tools/message-tool.test.ts` + - `src/gateway/server-methods/send.test.ts` diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index a765bc109..e60d5dd14 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -70,7 +70,8 @@ export function resolveSessionAgentIds(params: { sessionKey?: string; config?: C } { const defaultAgentId = resolveDefaultAgentId(params.config ?? {}); const sessionKey = params.sessionKey?.trim(); - const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null; + const normalizedSessionKey = sessionKey ? sessionKey.toLowerCase() : undefined; + const parsed = normalizedSessionKey ? parseAgentSessionKey(normalizedSessionKey) : null; const sessionAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : defaultAgentId; return { defaultAgentId, sessionAgentId }; } diff --git a/src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.returns-undefined-sessionkey-is-undefined.test.ts b/src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.returns-undefined-sessionkey-is-undefined.test.ts index f9bc1b69d..5d33ef490 100644 --- a/src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.returns-undefined-sessionkey-is-undefined.test.ts +++ b/src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.returns-undefined-sessionkey-is-undefined.test.ts @@ -128,7 +128,7 @@ describe("getDmHistoryLimitFromSessionKey", () => { slack: { dmHistoryLimit: 10 }, }, } as ClawdbotConfig; - expect(getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:C1", config)).toBeUndefined(); + expect(getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:c1", config)).toBeUndefined(); expect(getDmHistoryLimitFromSessionKey("telegram:slash:123", config)).toBeUndefined(); }); it("returns undefined for unknown provider", () => { diff --git a/src/agents/pi-embedded-runner.resolvesessionagentids.test.ts b/src/agents/pi-embedded-runner.resolvesessionagentids.test.ts index 3f37bc679..3889ae976 100644 --- a/src/agents/pi-embedded-runner.resolvesessionagentids.test.ts +++ b/src/agents/pi-embedded-runner.resolvesessionagentids.test.ts @@ -126,7 +126,7 @@ describe("resolveSessionAgentIds", () => { }); it("keeps the agent id for provider-qualified agent sessions", () => { const { sessionAgentId } = resolveSessionAgentIds({ - sessionKey: "agent:beta:slack:channel:C1", + sessionKey: "agent:beta:slack:channel:c1", config: cfg, }); expect(sessionAgentId).toBe("beta"); diff --git a/src/agents/sandbox-explain.test.ts b/src/agents/sandbox-explain.test.ts index 5ebef3fb1..9316379a3 100644 --- a/src/agents/sandbox-explain.test.ts +++ b/src/agents/sandbox-explain.test.ts @@ -106,7 +106,7 @@ describe("sandbox explain helpers", () => { const msg = formatSandboxToolPolicyBlockedMessage({ cfg, - sessionKey: "agent:main:whatsapp:group:G1", + sessionKey: "agent:main:whatsapp:group:g1", toolName: "browser", }); expect(msg).toBeTruthy(); diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 375b6ccb0..97c34c9ce 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -8,7 +8,6 @@ import { createMessageTool } from "./message-tool.js"; const mocks = vi.hoisted(() => ({ runMessageAction: vi.fn(), - appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), })); vi.mock("../../infra/outbound/message-action-runner.js", async () => { @@ -21,47 +20,9 @@ vi.mock("../../infra/outbound/message-action-runner.js", async () => { }; }); -vi.mock("../../config/sessions.js", async () => { - const actual = await vi.importActual( - "../../config/sessions.js", - ); - return { - ...actual, - appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript, - }; -}); - -describe("message tool mirroring", () => { - it("mirrors media filename for plugin-handled sends", async () => { - mocks.appendAssistantMessageToSessionTranscript.mockClear(); - mocks.runMessageAction.mockResolvedValue({ - kind: "send", - action: "send", - channel: "telegram", - handledBy: "plugin", - payload: {}, - dryRun: false, - } satisfies MessageActionRunResult); - - const tool = createMessageTool({ - agentSessionKey: "agent:main:main", - config: {} as never, - }); - - await tool.execute("1", { - action: "send", - target: "telegram:123", - message: "", - media: "https://example.com/files/report.pdf?sig=1", - }); - - expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith( - expect.objectContaining({ text: "report.pdf" }), - ); - }); - - it("does not mirror on dry-run", async () => { - mocks.appendAssistantMessageToSessionTranscript.mockClear(); +describe("message tool agent routing", () => { + it("derives agentId from the session key", async () => { + mocks.runMessageAction.mockClear(); mocks.runMessageAction.mockResolvedValue({ kind: "send", action: "send", @@ -72,7 +33,7 @@ describe("message tool mirroring", () => { } satisfies MessageActionRunResult); const tool = createMessageTool({ - agentSessionKey: "agent:main:main", + agentSessionKey: "agent:alpha:main", config: {} as never, }); @@ -82,7 +43,9 @@ describe("message tool mirroring", () => { message: "hi", }); - expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled(); + const call = mocks.runMessageAction.mock.calls[0]?.[0]; + expect(call?.agentId).toBe("alpha"); + expect(call?.sessionKey).toBeUndefined(); }); }); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 21974f074..e2c1bb8bb 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -11,10 +11,6 @@ import { import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; -import { - appendAssistantMessageToSessionTranscript, - resolveMirroredTranscriptText, -} from "../../config/sessions.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; @@ -377,36 +373,11 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { defaultAccountId: accountId ?? undefined, gateway, toolContext, - sessionKey: options?.agentSessionKey, agentId: options?.agentSessionKey ? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg }) : undefined, }); - if ( - action === "send" && - options?.agentSessionKey && - !result.dryRun && - result.handledBy === "plugin" - ) { - const mediaUrl = typeof params.media === "string" ? params.media : undefined; - const mirrorText = resolveMirroredTranscriptText({ - text: typeof params.message === "string" ? params.message : undefined, - mediaUrls: mediaUrl ? [mediaUrl] : undefined, - }); - if (mirrorText) { - const agentId = resolveSessionAgentId({ - sessionKey: options.agentSessionKey, - config: cfg, - }); - await appendAssistantMessageToSessionTranscript({ - agentId, - sessionKey: options.agentSessionKey, - text: mirrorText, - }); - } - } - const toolResult = getToolResult(result); if (toolResult) return toolResult; return jsonResult(result.payload); diff --git a/src/auto-reply/reply.raw-body.test.ts b/src/auto-reply/reply.raw-body.test.ts index 4f8060d9e..74842ae36 100644 --- a/src/auto-reply/reply.raw-body.test.ts +++ b/src/auto-reply/reply.raw-body.test.ts @@ -159,7 +159,7 @@ describe("RawBody directive parsing", () => { ChatType: "group", From: "+1222", To: "+1222", - SessionKey: "agent:main:whatsapp:group:G1", + SessionKey: "agent:main:whatsapp:group:g1", Provider: "whatsapp", Surface: "whatsapp", SenderE164: "+1222", @@ -182,7 +182,7 @@ describe("RawBody directive parsing", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Session: agent:main:whatsapp:group:G1"); + expect(text).toContain("Session: agent:main:whatsapp:group:g1"); expect(text).toContain("anthropic/claude-opus-4-5"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index d5de914aa..b39a977f4 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -37,7 +37,7 @@ describe("abort detection", () => { Body: `[Context]\nJake: /stop\n[from: Jake]`, RawBody: "/stop", ChatType: "group", - SessionKey: "agent:main:whatsapp:group:G1", + SessionKey: "agent:main:whatsapp:group:g1", }; const result = await initSessionState({ diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index c7983ae0c..d27b8e2a8 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -235,8 +235,8 @@ describe("handleCommands subagents", () => { addSubagentRunForTests({ runId: "run-1", childSessionKey: "agent:main:subagent:abc", - requesterSessionKey: "agent:main:slack:slash:U1", - requesterDisplayKey: "agent:main:slack:slash:U1", + requesterSessionKey: "agent:main:slack:slash:u1", + requesterDisplayKey: "agent:main:slack:slash:u1", task: "do thing", cleanup: "keep", createdAt: 1000, @@ -250,7 +250,7 @@ describe("handleCommands subagents", () => { CommandSource: "native", CommandTargetSessionKey: "agent:main:main", }); - params.sessionKey = "agent:main:slack:slash:U1"; + params.sessionKey = "agent:main:slack:slash:u1"; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("Subagents (current session)"); diff --git a/src/auto-reply/reply/model-selection.inherit-parent.test.ts b/src/auto-reply/reply/model-selection.inherit-parent.test.ts index 8a06f47a4..80eba66ea 100644 --- a/src/auto-reply/reply/model-selection.inherit-parent.test.ts +++ b/src/auto-reply/reply/model-selection.inherit-parent.test.ts @@ -45,8 +45,8 @@ async function resolveState(params: { describe("createModelSelectionState parent inheritance", () => { it("inherits parent override from explicit parentSessionKey", async () => { const cfg = {} as ClawdbotConfig; - const parentKey = "agent:main:discord:channel:C1"; - const sessionKey = "agent:main:discord:channel:C1:thread:123"; + const parentKey = "agent:main:discord:channel:c1"; + const sessionKey = "agent:main:discord:channel:c1:thread:123"; const parentEntry = makeEntry({ providerOverride: "openai", modelOverride: "gpt-4o", @@ -132,8 +132,8 @@ describe("createModelSelectionState parent inheritance", () => { }, }, } as ClawdbotConfig; - const parentKey = "agent:main:slack:channel:C1"; - const sessionKey = "agent:main:slack:channel:C1:thread:123"; + const parentKey = "agent:main:slack:channel:c1"; + const sessionKey = "agent:main:slack:channel:c1:thread:123"; const parentEntry = makeEntry({ providerOverride: "anthropic", modelOverride: "claude-opus-4-5", diff --git a/src/auto-reply/reply/session-resets.test.ts b/src/auto-reply/reply/session-resets.test.ts index 4f0903521..b478edc05 100644 --- a/src/auto-reply/reply/session-resets.test.ts +++ b/src/auto-reply/reply/session-resets.test.ts @@ -136,7 +136,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => { it("Reset trigger works when RawBody is clean but Body has wrapped context", async () => { const storePath = await createStorePath("clawdbot-group-rawbody-"); - const sessionKey = "agent:main:whatsapp:group:G1"; + const sessionKey = "agent:main:whatsapp:group:g1"; const existingSessionId = "existing-session-123"; await seedSessionStore({ storePath, diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index af548ecb0..1394a140e 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -37,7 +37,7 @@ describe("initSessionState thread forking", () => { ); const storePath = path.join(root, "sessions.json"); - const parentSessionKey = "agent:main:slack:channel:C1"; + const parentSessionKey = "agent:main:slack:channel:c1"; await saveSessionStore(storePath, { [parentSessionKey]: { sessionId: parentSessionId, @@ -50,7 +50,7 @@ describe("initSessionState thread forking", () => { session: { store: storePath }, } as ClawdbotConfig; - const threadSessionKey = "agent:main:slack:channel:C1:thread:123"; + const threadSessionKey = "agent:main:slack:channel:c1:thread:123"; const threadLabel = "Slack thread #general: starter"; const result = await initSessionState({ ctx: { @@ -117,7 +117,7 @@ describe("initSessionState RawBody", () => { Body: `[Chat messages since your last reply - for context]\n[WhatsApp ...] Someone: hello\n\n[Current message - respond to this]\n[WhatsApp ...] Jake: /status\n[from: Jake McInteer (+6421807830)]`, RawBody: "/status", ChatType: "group", - SessionKey: "agent:main:whatsapp:group:G1", + SessionKey: "agent:main:whatsapp:group:g1", }; const result = await initSessionState({ @@ -138,7 +138,7 @@ describe("initSessionState RawBody", () => { Body: `[Context]\nJake: /new\n[from: Jake]`, RawBody: "/new", ChatType: "group", - SessionKey: "agent:main:whatsapp:group:G1", + SessionKey: "agent:main:whatsapp:group:g1", }; const result = await initSessionState({ @@ -165,7 +165,7 @@ describe("initSessionState RawBody", () => { const ctx = { RawBody: "/NEW KeepThisCase", ChatType: "direct", - SessionKey: "agent:main:whatsapp:dm:S1", + SessionKey: "agent:main:whatsapp:dm:s1", }; const result = await initSessionState({ @@ -186,7 +186,7 @@ describe("initSessionState RawBody", () => { const ctx = { Body: "/status", - SessionKey: "agent:main:whatsapp:dm:S1", + SessionKey: "agent:main:whatsapp:dm:s1", }; const result = await initSessionState({ @@ -206,7 +206,7 @@ describe("initSessionState reset policy", () => { try { const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-daily-")); const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:whatsapp:dm:S1"; + const sessionKey = "agent:main:whatsapp:dm:s1"; const existingSessionId = "daily-session-id"; await saveSessionStore(storePath, { @@ -236,7 +236,7 @@ describe("initSessionState reset policy", () => { try { const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-daily-edge-")); const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:whatsapp:dm:S-edge"; + const sessionKey = "agent:main:whatsapp:dm:s-edge"; const existingSessionId = "daily-edge-session"; await saveSessionStore(storePath, { @@ -266,7 +266,7 @@ describe("initSessionState reset policy", () => { try { const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-idle-")); const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:whatsapp:dm:S2"; + const sessionKey = "agent:main:whatsapp:dm:s2"; const existingSessionId = "idle-session-id"; await saveSessionStore(storePath, { @@ -301,7 +301,7 @@ describe("initSessionState reset policy", () => { try { const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-thread-")); const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:slack:channel:C1:thread:123"; + const sessionKey = "agent:main:slack:channel:c1:thread:123"; const existingSessionId = "thread-session-id"; await saveSessionStore(storePath, { @@ -337,7 +337,7 @@ describe("initSessionState reset policy", () => { try { const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-thread-nosuffix-")); const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:discord:channel:C1"; + const sessionKey = "agent:main:discord:channel:c1"; const existingSessionId = "thread-nosuffix"; await saveSessionStore(storePath, { @@ -372,7 +372,7 @@ describe("initSessionState reset policy", () => { try { const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-type-default-")); const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:whatsapp:dm:S4"; + const sessionKey = "agent:main:whatsapp:dm:s4"; const existingSessionId = "type-default-session"; await saveSessionStore(storePath, { @@ -407,7 +407,7 @@ describe("initSessionState reset policy", () => { try { const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-legacy-")); const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:whatsapp:dm:S3"; + const sessionKey = "agent:main:whatsapp:dm:s3"; const existingSessionId = "legacy-session-id"; await saveSessionStore(storePath, { diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 83b6ef897..02e9b2c54 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -72,7 +72,7 @@ describe("doctor legacy state migrations", () => { expect(store["agent:main:+1666"]?.sessionId).toBe("b"); expect(store["+1555"]).toBeUndefined(); expect(store["+1666"]).toBeUndefined(); - expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c"); + expect(store["agent:main:slack:channel:c123"]?.sessionId).toBe("c"); expect(store["agent:main:unknown:group:abc"]?.sessionId).toBe("d"); expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e"); }); @@ -278,6 +278,27 @@ describe("doctor legacy state migrations", () => { expect(store["agent:main:main"]).toBeUndefined(); }); + it("lowercases agent session keys during canonicalization", async () => { + const root = await makeTempRoot(); + const cfg: ClawdbotConfig = {}; + const targetDir = path.join(root, "agents", "main", "sessions"); + writeJson5(path.join(targetDir, "sessions.json"), { + "agent:main:slack:channel:C123": { sessionId: "legacy", updatedAt: 10 }, + }); + + const detected = await detectLegacyStateMigrations({ + cfg, + env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + await runLegacyStateMigrations({ detected, now: () => 123 }); + + const store = JSON.parse( + fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), + ) as Record; + expect(store["agent:main:slack:channel:c123"]?.sessionId).toBe("legacy"); + expect(store["agent:main:slack:channel:C123"]).toBeUndefined(); + }); + it("auto-migrates when only target sessions contain legacy keys", async () => { const root = await makeTempRoot(); const cfg: ClawdbotConfig = {}; diff --git a/src/config/sessions/group.ts b/src/config/sessions/group.ts index 857b1aeaf..880c1e389 100644 --- a/src/config/sessions/group.ts +++ b/src/config/sessions/group.ts @@ -88,7 +88,7 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu ? parts.slice(2).join(":") : parts.slice(1).join(":") : from; - const finalId = id.trim(); + const finalId = id.trim().toLowerCase(); if (!finalId) return null; return { diff --git a/src/config/sessions/session-key.ts b/src/config/sessions/session-key.ts index bd129caa4..7cec6f646 100644 --- a/src/config/sessions/session-key.ts +++ b/src/config/sessions/session-key.ts @@ -23,7 +23,7 @@ export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) { */ export function resolveSessionKey(scope: SessionScope, ctx: MsgContext, mainKey?: string) { const explicit = ctx.SessionKey?.trim(); - if (explicit) return explicit; + if (explicit) return explicit.toLowerCase(); const raw = deriveSessionKey(scope, ctx); if (scope === "global") return raw; const canonicalMainKey = normalizeMainKey(mainKey); diff --git a/src/discord/monitor/message-handler.inbound-contract.test.ts b/src/discord/monitor/message-handler.inbound-contract.test.ts index 708c69993..634caea52 100644 --- a/src/discord/monitor/message-handler.inbound-contract.test.ts +++ b/src/discord/monitor/message-handler.inbound-contract.test.ts @@ -80,12 +80,12 @@ describe("discord processDiscordMessage inbound contract", () => { guildInfo: null, guildSlug: "", channelConfig: null, - baseSessionKey: "agent:main:discord:dm:U1", + baseSessionKey: "agent:main:discord:dm:u1", route: { agentId: "main", channel: "discord", accountId: "default", - sessionKey: "agent:main:discord:dm:U1", + sessionKey: "agent:main:discord:dm:u1", mainSessionKey: "agent:main:main", } as any, } as any); diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 2d30d0593..abd961965 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -6,6 +6,7 @@ import { sendHandlers } from "./send.js"; const mocks = vi.hoisted(() => ({ deliverOutboundPayloads: vi.fn(), appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), + recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })), })); vi.mock("../../config/config.js", async () => { @@ -37,6 +38,7 @@ vi.mock("../../config/sessions.js", async () => { return { ...actual, appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript, + recordSessionMetaFromInbound: mocks.recordSessionMetaFromInbound, }; }); @@ -134,4 +136,33 @@ describe("gateway send mirroring", () => { }), ); }); + + it("derives a target session key when none is provided", async () => { + mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m3", channel: "slack" }]); + + const respond = vi.fn(); + await sendHandlers.send({ + params: { + to: "channel:C1", + message: "hello", + channel: "slack", + idempotencyKey: "idem-4", + }, + respond, + context: makeContext(), + req: { type: "req", id: "1", method: "send" }, + client: null, + isWebchatConnect: () => false, + }); + + expect(mocks.recordSessionMetaFromInbound).toHaveBeenCalled(); + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + mirror: expect.objectContaining({ + sessionKey: "agent:main:slack:channel:resolved", + agentId: "main", + }), + }), + ); + }); }); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 971219de1..e4d292924 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -5,6 +5,10 @@ import { loadConfig } from "../../config/config.js"; import { createOutboundSendDeps } from "../../cli/deps.js"; import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads.js"; +import { + ensureOutboundSessionEntry, + resolveOutboundSessionRoute, +} from "../../infra/outbound/outbound-session.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import type { OutboundChannel } from "../../infra/outbound/targets.js"; import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; @@ -139,6 +143,30 @@ export const sendHandlers: GatewayRequestHandlers = { const mirrorMediaUrls = mirrorPayloads.flatMap( (payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), ); + const providedSessionKey = + typeof request.sessionKey === "string" && request.sessionKey.trim() + ? request.sessionKey.trim() + : undefined; + const derivedAgentId = resolveSessionAgentId({ config: cfg }); + // If callers omit sessionKey, derive a target session key from the outbound route. + const derivedRoute = !providedSessionKey + ? await resolveOutboundSessionRoute({ + cfg, + channel, + agentId: derivedAgentId, + accountId, + target: resolved.to, + }) + : null; + if (derivedRoute) { + await ensureOutboundSessionEntry({ + cfg, + agentId: derivedAgentId, + channel, + accountId, + route: derivedRoute, + }); + } const results = await deliverOutboundPayloads({ cfg, channel: outboundChannel, @@ -147,14 +175,17 @@ export const sendHandlers: GatewayRequestHandlers = { payloads: [{ text: message, mediaUrl: request.mediaUrl, mediaUrls }], gifPlayback: request.gifPlayback, deps: outboundDeps, - mirror: - typeof request.sessionKey === "string" && request.sessionKey.trim() + mirror: providedSessionKey + ? { + sessionKey: providedSessionKey, + agentId: resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg }), + text: mirrorText || message, + mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined, + } + : derivedRoute ? { - sessionKey: request.sessionKey.trim(), - agentId: resolveSessionAgentId({ - sessionKey: request.sessionKey.trim(), - config: cfg, - }), + sessionKey: derivedRoute.sessionKey, + agentId: derivedAgentId, text: mirrorText || message, mediaUrls: mirrorMediaUrls.length > 0 ? mirrorMediaUrls : undefined, } diff --git a/src/infra/outbound/message-action-runner.threading.test.ts b/src/infra/outbound/message-action-runner.threading.test.ts new file mode 100644 index 000000000..c8d8c5ba2 --- /dev/null +++ b/src/infra/outbound/message-action-runner.threading.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { slackPlugin } from "../../../extensions/slack/src/channel.js"; + +const mocks = vi.hoisted(() => ({ + executeSendAction: vi.fn(), + recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })), +})); + +vi.mock("./outbound-send-service.js", async () => { + const actual = await vi.importActual( + "./outbound-send-service.js", + ); + return { + ...actual, + executeSendAction: mocks.executeSendAction, + }; +}); + +vi.mock("../../config/sessions.js", async () => { + const actual = await vi.importActual( + "../../config/sessions.js", + ); + return { + ...actual, + recordSessionMetaFromInbound: mocks.recordSessionMetaFromInbound, + }; +}); + +import { runMessageAction } from "./message-action-runner.js"; + +const slackConfig = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, +} as ClawdbotConfig; + +describe("runMessageAction Slack threading", () => { + beforeEach(async () => { + const { createPluginRuntime } = await import("../../plugins/runtime/index.js"); + const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js"); + const runtime = createPluginRuntime(); + setSlackRuntime(runtime); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "slack", + source: "test", + plugin: slackPlugin, + }, + ]), + ); + }); + + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + mocks.executeSendAction.mockReset(); + mocks.recordSessionMetaFromInbound.mockReset(); + }); + + it("uses toolContext thread when auto-threading is active", async () => { + mocks.executeSendAction.mockResolvedValue({ + handledBy: "plugin", + payload: {}, + }); + + await runMessageAction({ + cfg: slackConfig, + action: "send", + params: { + channel: "slack", + target: "channel:C123", + message: "hi", + }, + toolContext: { + currentChannelId: "C123", + currentThreadTs: "111.222", + replyToMode: "all", + }, + agentId: "main", + }); + + const call = mocks.executeSendAction.mock.calls[0]?.[0]; + expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:111.222"); + }); +}); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index b873fa264..a3a01e613 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -7,6 +7,7 @@ import { readStringArrayParam, readStringParam, } from "../../agents/tools/common.js"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; import type { @@ -26,6 +27,7 @@ import { resolveMessageChannelSelection, } from "./channel-selection.js"; import { applyTargetToParams } from "./channel-target.js"; +import { ensureOutboundSessionEntry, resolveOutboundSessionRoute } from "./outbound-session.js"; import type { OutboundSendDeps } from "./deliver.js"; import type { MessagePollResult, MessageSendResult } from "./message.js"; import { @@ -37,9 +39,10 @@ import { } from "./outbound-policy.js"; import { executePollAction, executeSendAction } from "./outbound-send-service.js"; import { actionHasTarget, actionRequiresTarget } from "./message-action-spec.js"; -import { resolveChannelTarget } from "./target-resolver.js"; +import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js"; import { loadWebMedia } from "../../web/media.js"; import { extensionForMime } from "../../media/mime.js"; +import { parseSlackTarget } from "../../slack/targets.js"; export type MessageActionRunnerGateway = { url?: string; @@ -204,6 +207,21 @@ function readBooleanParam(params: Record, key: string): boolean return undefined; } +function resolveSlackAutoThreadId(params: { + to: string; + toolContext?: ChannelThreadingToolContext; +}): string | undefined { + const context = params.toolContext; + if (!context?.currentThreadTs || !context.currentChannelId) return undefined; + // Only mirror auto-threading when Slack would reply in the active thread for this channel. + if (context.replyToMode !== "all" && context.replyToMode !== "first") return undefined; + const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" }); + if (!parsedTarget || parsedTarget.kind !== "channel") return undefined; + if (parsedTarget.id !== context.currentChannelId) return undefined; + if (context.replyToMode === "first" && context.hasRepliedRef?.value) return undefined; + return context.currentThreadTs; +} + function resolveAttachmentMaxBytes(params: { cfg: ClawdbotConfig; channel: ChannelId; @@ -440,7 +458,8 @@ async function resolveActionTarget(params: { action: ChannelMessageActionName; args: Record; accountId?: string | null; -}): Promise { +}): Promise { + let resolvedTarget: ResolvedMessagingTarget | undefined; const toRaw = typeof params.args.to === "string" ? params.args.to.trim() : ""; if (toRaw) { const resolved = await resolveChannelTarget({ @@ -451,6 +470,7 @@ async function resolveActionTarget(params: { }); if (resolved.ok) { params.args.to = resolved.target.to; + resolvedTarget = resolved.target; } else { throw resolved.error; } @@ -474,6 +494,7 @@ async function resolveActionTarget(params: { throw resolved.error; } } + return resolvedTarget; } type ResolvedActionContext = { @@ -484,6 +505,8 @@ type ResolvedActionContext = { dryRun: boolean; gateway?: MessageActionRunnerGateway; input: RunMessageActionParams; + agentId?: string; + resolvedTarget?: ResolvedMessagingTarget; }; function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined { if (!input.gateway) return undefined; @@ -570,7 +593,7 @@ async function handleBroadcastAction( } async function handleSendAction(ctx: ResolvedActionContext): Promise { - const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx; + const { cfg, params, channel, accountId, dryRun, gateway, input, agentId, resolvedTarget } = ctx; const action: ChannelMessageActionName = "send"; const to = readStringParam(params, "to", { required: true }); // Support media, path, and filePath parameters for attachments @@ -621,6 +644,38 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined; const send = await executeSendAction({ ctx: { cfg, @@ -632,10 +687,12 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise { const cfg = input.cfg; const params = { ...input.params }; + const resolvedAgentId = + input.agentId ?? + (input.sessionKey + ? resolveSessionAgentId({ sessionKey: input.sessionKey, config: cfg }) + : undefined); parseButtonsParam(params); parseCardParam(params); @@ -839,7 +901,7 @@ export async function runMessageAction( dryRun, }); - await resolveActionTarget({ + const resolvedTarget = await resolveActionTarget({ cfg, channel, action, @@ -866,6 +928,8 @@ export async function runMessageAction( dryRun, gateway, input, + agentId: resolvedAgentId, + resolvedTarget, }); } diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index d67f18678..fcb90c295 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -47,6 +47,8 @@ type MessageSendParams = { mirror?: { sessionKey: string; agentId?: string; + text?: string; + mediaUrls?: string[]; }; }; diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index 87f575331..dd5dfd5e6 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -2,6 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import type { ClawdbotConfig } from "../../config/config.js"; +import { appendAssistantMessageToSessionTranscript } from "../../config/sessions.js"; import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js"; import type { OutboundSendDeps } from "./deliver.js"; import type { MessagePollResult, MessageSendResult } from "./message.js"; @@ -28,6 +29,8 @@ export type OutboundSendContext = { mirror?: { sessionKey: string; agentId?: string; + text?: string; + mediaUrls?: string[]; }; }; @@ -79,6 +82,19 @@ export async function executeSendAction(params: { dryRun: params.ctx.dryRun, }); if (handled) { + if (params.ctx.mirror) { + const mirrorText = params.ctx.mirror.text ?? params.message; + const mirrorMediaUrls = + params.ctx.mirror.mediaUrls ?? + params.mediaUrls ?? + (params.mediaUrl ? [params.mediaUrl] : undefined); + await appendAssistantMessageToSessionTranscript({ + agentId: params.ctx.mirror.agentId, + sessionKey: params.ctx.mirror.sessionKey, + text: mirrorText, + mediaUrls: mirrorMediaUrls, + }); + } return { handledBy: "plugin", payload: extractToolPayload(handled), diff --git a/src/infra/outbound/outbound-session.test.ts b/src/infra/outbound/outbound-session.test.ts new file mode 100644 index 000000000..978de5c16 --- /dev/null +++ b/src/infra/outbound/outbound-session.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveOutboundSessionRoute } from "./outbound-session.js"; + +const baseConfig = {} as ClawdbotConfig; + +describe("resolveOutboundSessionRoute", () => { + it("builds Slack thread session keys", async () => { + const route = await resolveOutboundSessionRoute({ + cfg: baseConfig, + channel: "slack", + agentId: "main", + target: "channel:C123", + replyToId: "456", + }); + + expect(route?.sessionKey).toBe("agent:main:slack:channel:c123:thread:456"); + expect(route?.from).toBe("slack:channel:C123"); + expect(route?.to).toBe("channel:C123"); + expect(route?.threadId).toBe("456"); + }); + + it("uses Telegram topic ids in group session keys", async () => { + const route = await resolveOutboundSessionRoute({ + cfg: baseConfig, + channel: "telegram", + agentId: "main", + target: "-100123456:topic:42", + }); + + expect(route?.sessionKey).toBe("agent:main:telegram:group:-100123456:topic:42"); + expect(route?.from).toBe("telegram:group:-100123456:topic:42"); + expect(route?.to).toBe("telegram:-100123456"); + expect(route?.threadId).toBe(42); + }); + + it("treats Telegram usernames as DMs when unresolved", async () => { + const cfg = { session: { dmScope: "per-channel-peer" } } as ClawdbotConfig; + const route = await resolveOutboundSessionRoute({ + cfg, + channel: "telegram", + agentId: "main", + target: "@alice", + }); + + expect(route?.sessionKey).toBe("agent:main:telegram:dm:@alice"); + expect(route?.chatType).toBe("direct"); + }); + + it("honors dmScope identity links", async () => { + const cfg = { + session: { + dmScope: "per-peer", + identityLinks: { + alice: ["discord:123"], + }, + }, + } as ClawdbotConfig; + + const route = await resolveOutboundSessionRoute({ + cfg, + channel: "discord", + agentId: "main", + target: "user:123", + }); + + expect(route?.sessionKey).toBe("agent:main:dm:alice"); + }); + + it("treats Zalo Personal DM targets as direct sessions", async () => { + const cfg = { session: { dmScope: "per-channel-peer" } } as ClawdbotConfig; + const route = await resolveOutboundSessionRoute({ + cfg, + channel: "zalouser", + agentId: "main", + target: "123456", + }); + + expect(route?.sessionKey).toBe("agent:main:zalouser:dm:123456"); + expect(route?.chatType).toBe("direct"); + }); + + it("uses group session keys for Slack mpim allowlist entries", async () => { + const cfg = { + channels: { + slack: { + dm: { + groupChannels: ["G123"], + }, + }, + }, + } as ClawdbotConfig; + + const route = await resolveOutboundSessionRoute({ + cfg, + channel: "slack", + agentId: "main", + target: "channel:G123", + }); + + expect(route?.sessionKey).toBe("agent:main:slack:group:g123"); + expect(route?.from).toBe("slack:group:G123"); + }); +}); diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts new file mode 100644 index 000000000..d50b9d4a9 --- /dev/null +++ b/src/infra/outbound/outbound-session.ts @@ -0,0 +1,834 @@ +import type { MsgContext } from "../../auto-reply/templating.js"; +import { getChannelPlugin } from "../../channels/plugins/index.js"; +import type { ChannelId } from "../../channels/plugins/types.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { recordSessionMetaFromInbound, resolveStorePath } from "../../config/sessions.js"; +import { parseDiscordTarget } from "../../discord/targets.js"; +import { parseIMessageTarget, normalizeIMessageHandle } from "../../imessage/targets.js"; +import { + buildAgentSessionKey, + type RoutePeer, + type RoutePeerKind, +} from "../../routing/resolve-route.js"; +import { resolveThreadSessionKeys } from "../../routing/session-key.js"; +import { resolveSlackAccount } from "../../slack/accounts.js"; +import { createSlackWebClient } from "../../slack/client.js"; +import { normalizeAllowListLower } from "../../slack/monitor/allow-list.js"; +import { + resolveSignalPeerId, + resolveSignalRecipient, + resolveSignalSender, +} from "../../signal/identity.js"; +import { parseSlackTarget } from "../../slack/targets.js"; +import { buildTelegramGroupPeerId } from "../../telegram/bot/helpers.js"; +import { resolveTelegramTargetChatType } from "../../telegram/inline-buttons.js"; +import { parseTelegramTarget } from "../../telegram/targets.js"; +import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; +import type { ResolvedMessagingTarget } from "./target-resolver.js"; + +export type OutboundSessionRoute = { + sessionKey: string; + baseSessionKey: string; + peer: RoutePeer; + chatType: "direct" | "group" | "channel"; + from: string; + to: string; + threadId?: string | number; +}; + +export type ResolveOutboundSessionRouteParams = { + cfg: ClawdbotConfig; + channel: ChannelId; + agentId: string; + accountId?: string | null; + target: string; + resolvedTarget?: ResolvedMessagingTarget; + replyToId?: string | null; + threadId?: string | number | null; +}; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const UUID_COMPACT_RE = /^[0-9a-f]{32}$/i; +// Cache Slack channel type lookups to avoid repeated API calls. +const SLACK_CHANNEL_TYPE_CACHE = new Map(); + +function looksLikeUuid(value: string): boolean { + if (UUID_RE.test(value) || UUID_COMPACT_RE.test(value)) return true; + const compact = value.replace(/-/g, ""); + if (!/^[0-9a-f]+$/i.test(compact)) return false; + return /[a-f]/i.test(compact); +} + +function normalizeThreadId(value?: string | number | null): string | undefined { + if (value == null) return undefined; + if (typeof value === "number") { + if (!Number.isFinite(value)) return undefined; + return String(Math.trunc(value)); + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function stripProviderPrefix(raw: string, channel: string): string { + const trimmed = raw.trim(); + const lower = trimmed.toLowerCase(); + const prefix = `${channel.toLowerCase()}:`; + if (lower.startsWith(prefix)) return trimmed.slice(prefix.length).trim(); + return trimmed; +} + +function stripKindPrefix(raw: string): string { + return raw.replace(/^(user|channel|group|conversation|room|dm):/i, "").trim(); +} + +function inferPeerKind(params: { + channel: ChannelId; + resolvedTarget?: ResolvedMessagingTarget; +}): RoutePeerKind { + const resolvedKind = params.resolvedTarget?.kind; + if (resolvedKind === "user") return "dm"; + if (resolvedKind === "channel") return "channel"; + if (resolvedKind === "group") { + const plugin = getChannelPlugin(params.channel); + const chatTypes = plugin?.capabilities?.chatTypes ?? []; + const supportsChannel = chatTypes.includes("channel"); + const supportsGroup = chatTypes.includes("group"); + if (supportsChannel && !supportsGroup) return "channel"; + return "group"; + } + return "dm"; +} + +function buildBaseSessionKey(params: { + cfg: ClawdbotConfig; + agentId: string; + channel: ChannelId; + peer: RoutePeer; +}): string { + return buildAgentSessionKey({ + agentId: params.agentId, + channel: params.channel, + peer: params.peer, + dmScope: params.cfg.session?.dmScope ?? "main", + identityLinks: params.cfg.session?.identityLinks, + }); +} + +// Best-effort mpim detection: allowlist/config, then Slack API (if token available). +async function resolveSlackChannelType(params: { + cfg: ClawdbotConfig; + accountId?: string | null; + channelId: string; +}): Promise<"channel" | "group" | "dm" | "unknown"> { + const channelId = params.channelId.trim(); + if (!channelId) return "unknown"; + const cached = SLACK_CHANNEL_TYPE_CACHE.get(`${params.accountId ?? "default"}:${channelId}`); + if (cached) return cached; + + const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); + const groupChannels = normalizeAllowListLower(account.dm?.groupChannels); + const channelIdLower = channelId.toLowerCase(); + if ( + groupChannels.includes(channelIdLower) || + groupChannels.includes(`slack:${channelIdLower}`) || + groupChannels.includes(`channel:${channelIdLower}`) || + groupChannels.includes(`group:${channelIdLower}`) || + groupChannels.includes(`mpim:${channelIdLower}`) + ) { + SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "group"); + return "group"; + } + + const channelKeys = Object.keys(account.channels ?? {}); + if ( + channelKeys.some((key) => { + const normalized = key.trim().toLowerCase(); + return ( + normalized === channelIdLower || + normalized === `channel:${channelIdLower}` || + normalized.replace(/^#/, "") === channelIdLower + ); + }) + ) { + SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "channel"); + return "channel"; + } + + const token = + account.botToken?.trim() || + (typeof account.config.userToken === "string" ? account.config.userToken.trim() : ""); + if (!token) { + SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "unknown"); + return "unknown"; + } + + try { + const client = createSlackWebClient(token); + const info = await client.conversations.info({ channel: channelId }); + const channel = info.channel as { is_im?: boolean; is_mpim?: boolean } | undefined; + const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel"; + SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, type); + return type; + } catch { + SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "unknown"); + return "unknown"; + } +} + +async function resolveSlackSession( + params: ResolveOutboundSessionRouteParams, +): Promise { + const parsed = parseSlackTarget(params.target, { defaultKind: "channel" }); + if (!parsed) return null; + const isDm = parsed.kind === "user"; + let peerKind: RoutePeerKind = isDm ? "dm" : "channel"; + if (!isDm && /^G/i.test(parsed.id)) { + // Slack mpim/group DMs share the G-prefix; detect to align session keys with inbound. + const channelType = await resolveSlackChannelType({ + cfg: params.cfg, + accountId: params.accountId, + channelId: parsed.id, + }); + if (channelType === "group") peerKind = "group"; + if (channelType === "dm") peerKind = "dm"; + } + const peer: RoutePeer = { + kind: peerKind, + id: parsed.id, + }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "slack", + peer, + }); + const threadId = normalizeThreadId(params.threadId ?? params.replyToId); + const threadKeys = resolveThreadSessionKeys({ + baseSessionKey, + threadId, + }); + return { + sessionKey: threadKeys.sessionKey, + baseSessionKey, + peer, + chatType: peerKind === "dm" ? "direct" : "channel", + from: + peerKind === "dm" + ? `slack:${parsed.id}` + : peerKind === "group" + ? `slack:group:${parsed.id}` + : `slack:channel:${parsed.id}`, + to: peerKind === "dm" ? `user:${parsed.id}` : `channel:${parsed.id}`, + threadId, + }; +} + +function resolveDiscordSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + const parsed = parseDiscordTarget(params.target, { defaultKind: "channel" }); + if (!parsed) return null; + const isDm = parsed.kind === "user"; + const peer: RoutePeer = { + kind: isDm ? "dm" : "channel", + id: parsed.id, + }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "discord", + peer, + }); + const explicitThreadId = normalizeThreadId(params.threadId); + const threadCandidate = explicitThreadId ?? normalizeThreadId(params.replyToId); + // Discord threads use their own channel id; avoid adding a :thread suffix. + const threadKeys = resolveThreadSessionKeys({ + baseSessionKey, + threadId: threadCandidate, + useSuffix: false, + }); + return { + sessionKey: threadKeys.sessionKey, + baseSessionKey, + peer, + chatType: isDm ? "direct" : "channel", + from: isDm ? `discord:${parsed.id}` : `discord:channel:${parsed.id}`, + to: isDm ? `user:${parsed.id}` : `channel:${parsed.id}`, + threadId: explicitThreadId ?? undefined, + }; +} + +function resolveTelegramSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + const parsed = parseTelegramTarget(params.target); + const chatId = parsed.chatId.trim(); + if (!chatId) return null; + const parsedThreadId = parsed.messageThreadId; + const fallbackThreadId = normalizeThreadId(params.threadId); + const resolvedThreadId = + parsedThreadId ?? (fallbackThreadId ? Number.parseInt(fallbackThreadId, 10) : undefined); + // Telegram topics are encoded in the peer id (chatId:topic:). + const chatType = resolveTelegramTargetChatType(params.target); + // If the target is a username and we lack a resolvedTarget, default to DM to avoid group keys. + const isGroup = + chatType === "group" || + (chatType === "unknown" && + params.resolvedTarget?.kind && + params.resolvedTarget.kind !== "user"); + const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : chatId; + const peer: RoutePeer = { + kind: isGroup ? "group" : "dm", + id: peerId, + }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "telegram", + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: isGroup ? "group" : "direct", + from: isGroup ? `telegram:group:${peerId}` : `telegram:${chatId}`, + to: `telegram:${chatId}`, + threadId: resolvedThreadId, + }; +} + +function resolveWhatsAppSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + const normalized = normalizeWhatsAppTarget(params.target); + if (!normalized) return null; + const isGroup = isWhatsAppGroupJid(normalized); + const peer: RoutePeer = { + kind: isGroup ? "group" : "dm", + id: normalized, + }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "whatsapp", + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: isGroup ? "group" : "direct", + from: normalized, + to: normalized, + }; +} + +function resolveSignalSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + const stripped = stripProviderPrefix(params.target, "signal"); + const lowered = stripped.toLowerCase(); + if (lowered.startsWith("group:")) { + const groupId = stripped.slice("group:".length).trim(); + if (!groupId) return null; + const peer: RoutePeer = { kind: "group", id: groupId }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "signal", + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: "group", + from: `group:${groupId}`, + to: `group:${groupId}`, + }; + } + + let recipient = stripped.trim(); + if (lowered.startsWith("username:")) { + recipient = stripped.slice("username:".length).trim(); + } else if (lowered.startsWith("u:")) { + recipient = stripped.slice("u:".length).trim(); + } + if (!recipient) return null; + + const uuidCandidate = recipient.toLowerCase().startsWith("uuid:") + ? recipient.slice("uuid:".length) + : recipient; + const sender = resolveSignalSender({ + sourceUuid: looksLikeUuid(uuidCandidate) ? uuidCandidate : null, + sourceNumber: looksLikeUuid(uuidCandidate) ? null : recipient, + }); + const peerId = sender ? resolveSignalPeerId(sender) : recipient; + const displayRecipient = sender ? resolveSignalRecipient(sender) : recipient; + const peer: RoutePeer = { kind: "dm", id: peerId }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "signal", + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: "direct", + from: `signal:${displayRecipient}`, + to: `signal:${displayRecipient}`, + }; +} + +function resolveIMessageSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + const parsed = parseIMessageTarget(params.target); + if (parsed.kind === "handle") { + const handle = normalizeIMessageHandle(parsed.to); + if (!handle) return null; + const peer: RoutePeer = { kind: "dm", id: handle }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "imessage", + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: "direct", + from: `imessage:${handle}`, + to: `imessage:${handle}`, + }; + } + + const peerId = + parsed.kind === "chat_id" + ? String(parsed.chatId) + : parsed.kind === "chat_guid" + ? parsed.chatGuid + : parsed.chatIdentifier; + if (!peerId) return null; + const peer: RoutePeer = { kind: "group", id: peerId }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "imessage", + peer, + }); + const toPrefix = + parsed.kind === "chat_id" + ? "chat_id" + : parsed.kind === "chat_guid" + ? "chat_guid" + : "chat_identifier"; + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: "group", + from: `imessage:group:${peerId}`, + to: `${toPrefix}:${peerId}`, + }; +} + +function resolveMatrixSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + const stripped = stripProviderPrefix(params.target, "matrix"); + const isUser = + params.resolvedTarget?.kind === "user" || stripped.startsWith("@") || /^user:/i.test(stripped); + const rawId = stripKindPrefix(stripped); + if (!rawId) return null; + const peer: RoutePeer = { kind: isUser ? "dm" : "channel", id: rawId }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "matrix", + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: isUser ? "direct" : "channel", + from: isUser ? `matrix:${rawId}` : `matrix:channel:${rawId}`, + to: `room:${rawId}`, + }; +} + +function resolveMSTeamsSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + let trimmed = params.target.trim(); + if (!trimmed) return null; + trimmed = trimmed.replace(/^(msteams|teams):/i, "").trim(); + + const lower = trimmed.toLowerCase(); + const isUser = lower.startsWith("user:"); + const rawId = stripKindPrefix(trimmed); + if (!rawId) return null; + const conversationId = rawId.split(";")[0] ?? rawId; + const isChannel = !isUser && /@thread\.tacv2/i.test(conversationId); + const peer: RoutePeer = { + kind: isUser ? "dm" : isChannel ? "channel" : "group", + id: conversationId, + }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "msteams", + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: isUser ? "direct" : isChannel ? "channel" : "group", + from: isUser + ? `msteams:${conversationId}` + : isChannel + ? `msteams:channel:${conversationId}` + : `msteams:group:${conversationId}`, + to: isUser ? `user:${conversationId}` : `conversation:${conversationId}`, + }; +} + +function resolveMattermostSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + let trimmed = params.target.trim(); + if (!trimmed) return null; + trimmed = trimmed.replace(/^mattermost:/i, "").trim(); + const lower = trimmed.toLowerCase(); + const isUser = lower.startsWith("user:") || trimmed.startsWith("@"); + if (trimmed.startsWith("@")) { + trimmed = trimmed.slice(1).trim(); + } + const rawId = stripKindPrefix(trimmed); + if (!rawId) return null; + const peer: RoutePeer = { kind: isUser ? "dm" : "channel", id: rawId }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "mattermost", + peer, + }); + const threadId = normalizeThreadId(params.replyToId ?? params.threadId); + const threadKeys = resolveThreadSessionKeys({ + baseSessionKey, + threadId, + }); + return { + sessionKey: threadKeys.sessionKey, + baseSessionKey, + peer, + chatType: isUser ? "direct" : "channel", + from: isUser ? `mattermost:${rawId}` : `mattermost:channel:${rawId}`, + to: isUser ? `user:${rawId}` : `channel:${rawId}`, + threadId, + }; +} + +function resolveBlueBubblesSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + const stripped = stripProviderPrefix(params.target, "bluebubbles"); + const lower = stripped.toLowerCase(); + const isGroup = + lower.startsWith("chat_id:") || + lower.startsWith("chat_guid:") || + lower.startsWith("chat_identifier:") || + lower.startsWith("group:"); + const peerId = isGroup + ? stripKindPrefix(stripped) + : stripped.replace(/^(imessage|sms|auto):/i, ""); + if (!peerId) return null; + const peer: RoutePeer = { + kind: isGroup ? "group" : "dm", + id: peerId, + }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "bluebubbles", + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: isGroup ? "group" : "direct", + from: isGroup ? `group:${peerId}` : `bluebubbles:${peerId}`, + to: `bluebubbles:${stripped}`, + }; +} + +function resolveNextcloudTalkSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + let trimmed = params.target.trim(); + if (!trimmed) return null; + trimmed = trimmed.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").trim(); + trimmed = trimmed.replace(/^room:/i, "").trim(); + if (!trimmed) return null; + const peer: RoutePeer = { kind: "group", id: trimmed }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "nextcloud-talk", + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: "group", + from: `nextcloud-talk:room:${trimmed}`, + to: `nextcloud-talk:${trimmed}`, + }; +} + +function resolveZaloSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + const trimmed = stripProviderPrefix(params.target, "zalo") + .replace(/^(zl):/i, "") + .trim(); + if (!trimmed) return null; + const isGroup = trimmed.toLowerCase().startsWith("group:"); + const peerId = stripKindPrefix(trimmed); + const peer: RoutePeer = { kind: isGroup ? "group" : "dm", id: peerId }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "zalo", + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: isGroup ? "group" : "direct", + from: isGroup ? `zalo:group:${peerId}` : `zalo:${peerId}`, + to: `zalo:${peerId}`, + }; +} + +function resolveZalouserSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + const trimmed = stripProviderPrefix(params.target, "zalouser") + .replace(/^(zlu):/i, "") + .trim(); + if (!trimmed) return null; + const isGroup = trimmed.toLowerCase().startsWith("group:"); + const peerId = stripKindPrefix(trimmed); + // Keep DM vs group aligned with inbound sessions for Zalo Personal. + const peer: RoutePeer = { kind: isGroup ? "group" : "dm", id: peerId }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "zalouser", + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: isGroup ? "group" : "direct", + from: isGroup ? `zalouser:group:${peerId}` : `zalouser:${peerId}`, + to: `zalouser:${peerId}`, + }; +} + +function resolveNostrSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + const trimmed = stripProviderPrefix(params.target, "nostr").trim(); + if (!trimmed) return null; + const peer: RoutePeer = { kind: "dm", id: trimmed }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "nostr", + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: "direct", + from: `nostr:${trimmed}`, + to: `nostr:${trimmed}`, + }; +} + +function normalizeTlonShip(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return trimmed; + return trimmed.startsWith("~") ? trimmed : `~${trimmed}`; +} + +function resolveTlonSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + let trimmed = stripProviderPrefix(params.target, "tlon"); + trimmed = trimmed.trim(); + if (!trimmed) return null; + const lower = trimmed.toLowerCase(); + let isGroup = + lower.startsWith("group:") || lower.startsWith("room:") || lower.startsWith("chat/"); + let peerId = trimmed; + if (lower.startsWith("group:") || lower.startsWith("room:")) { + peerId = trimmed.replace(/^(group|room):/i, "").trim(); + if (!peerId.startsWith("chat/")) { + const parts = peerId.split("/").filter(Boolean); + if (parts.length === 2) { + peerId = `chat/${normalizeTlonShip(parts[0])}/${parts[1]}`; + } + } + isGroup = true; + } else if (lower.startsWith("dm:")) { + peerId = normalizeTlonShip(trimmed.slice("dm:".length)); + isGroup = false; + } else if (lower.startsWith("chat/")) { + peerId = trimmed; + isGroup = true; + } else if (trimmed.includes("/")) { + const parts = trimmed.split("/").filter(Boolean); + if (parts.length === 2) { + peerId = `chat/${normalizeTlonShip(parts[0])}/${parts[1]}`; + isGroup = true; + } + } else { + peerId = normalizeTlonShip(trimmed); + } + + const peer: RoutePeer = { kind: isGroup ? "group" : "dm", id: peerId }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: "tlon", + peer, + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType: isGroup ? "group" : "direct", + from: isGroup ? `tlon:group:${peerId}` : `tlon:${peerId}`, + to: `tlon:${peerId}`, + }; +} + +function resolveFallbackSession( + params: ResolveOutboundSessionRouteParams, +): OutboundSessionRoute | null { + const trimmed = stripProviderPrefix(params.target, params.channel).trim(); + if (!trimmed) return null; + const peerKind = inferPeerKind({ + channel: params.channel, + resolvedTarget: params.resolvedTarget, + }); + const peerId = stripKindPrefix(trimmed); + if (!peerId) return null; + const peer: RoutePeer = { kind: peerKind, id: peerId }; + const baseSessionKey = buildBaseSessionKey({ + cfg: params.cfg, + agentId: params.agentId, + channel: params.channel, + peer, + }); + const chatType = peerKind === "dm" ? "direct" : peerKind === "channel" ? "channel" : "group"; + const from = + peerKind === "dm" ? `${params.channel}:${peerId}` : `${params.channel}:${peerKind}:${peerId}`; + const toPrefix = peerKind === "dm" ? "user" : "channel"; + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer, + chatType, + from, + to: `${toPrefix}:${peerId}`, + }; +} + +export async function resolveOutboundSessionRoute( + params: ResolveOutboundSessionRouteParams, +): Promise { + const target = params.target.trim(); + if (!target) return null; + switch (params.channel) { + case "slack": + return await resolveSlackSession({ ...params, target }); + case "discord": + return resolveDiscordSession({ ...params, target }); + case "telegram": + return resolveTelegramSession({ ...params, target }); + case "whatsapp": + return resolveWhatsAppSession({ ...params, target }); + case "signal": + return resolveSignalSession({ ...params, target }); + case "imessage": + return resolveIMessageSession({ ...params, target }); + case "matrix": + return resolveMatrixSession({ ...params, target }); + case "msteams": + return resolveMSTeamsSession({ ...params, target }); + case "mattermost": + return resolveMattermostSession({ ...params, target }); + case "bluebubbles": + return resolveBlueBubblesSession({ ...params, target }); + case "nextcloud-talk": + return resolveNextcloudTalkSession({ ...params, target }); + case "zalo": + return resolveZaloSession({ ...params, target }); + case "zalouser": + return resolveZalouserSession({ ...params, target }); + case "nostr": + return resolveNostrSession({ ...params, target }); + case "tlon": + return resolveTlonSession({ ...params, target }); + default: + return resolveFallbackSession({ ...params, target }); + } +} + +export async function ensureOutboundSessionEntry(params: { + cfg: ClawdbotConfig; + agentId: string; + channel: ChannelId; + accountId?: string | null; + route: OutboundSessionRoute; +}): Promise { + const storePath = resolveStorePath(params.cfg.session?.store, { + agentId: params.agentId, + }); + const ctx: MsgContext = { + From: params.route.from, + To: params.route.to, + SessionKey: params.route.sessionKey, + AccountId: params.accountId ?? undefined, + ChatType: params.route.chatType, + Provider: params.channel, + Surface: params.channel, + MessageThreadId: params.route.threadId, + OriginatingChannel: params.channel, + OriginatingTo: params.route.to, + }; + try { + await recordSessionMetaFromInbound({ + storePath, + sessionKey: params.route.sessionKey, + ctx, + }); + } catch { + // Do not block outbound sends on session meta writes. + } +} diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 4717e714b..b32499a6c 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -85,40 +85,40 @@ function canonicalizeSessionKeyForAgent(params: { const agentId = normalizeAgentId(params.agentId); const raw = params.key.trim(); if (!raw) return raw; - if (raw === "global" || raw === "unknown") return raw; + if (raw.toLowerCase() === "global" || raw.toLowerCase() === "unknown") return raw.toLowerCase(); const canonicalMain = canonicalizeMainSessionAlias({ cfg: { session: { scope: params.scope, mainKey: params.mainKey } }, agentId, sessionKey: raw, }); - if (canonicalMain !== raw) return canonicalMain; + if (canonicalMain !== raw) return canonicalMain.toLowerCase(); - if (raw.startsWith("agent:")) return raw; + if (raw.toLowerCase().startsWith("agent:")) return raw.toLowerCase(); if (raw.toLowerCase().startsWith("subagent:")) { const rest = raw.slice("subagent:".length); - return `agent:${agentId}:subagent:${rest}`; + return `agent:${agentId}:subagent:${rest}`.toLowerCase(); } if (raw.startsWith("group:")) { const id = raw.slice("group:".length).trim(); if (!id) return raw; const channel = id.toLowerCase().includes("@g.us") ? "whatsapp" : "unknown"; - return `agent:${agentId}:${channel}:group:${id}`; + return `agent:${agentId}:${channel}:group:${id}`.toLowerCase(); } if (!raw.includes(":") && raw.toLowerCase().includes("@g.us")) { - return `agent:${agentId}:whatsapp:group:${raw}`; + return `agent:${agentId}:whatsapp:group:${raw}`.toLowerCase(); } if (raw.toLowerCase().startsWith("whatsapp:") && raw.toLowerCase().includes("@g.us")) { const remainder = raw.slice("whatsapp:".length).trim(); const cleaned = remainder.replace(/^group:/i, "").trim(); if (cleaned && !isSurfaceGroupKey(raw)) { - return `agent:${agentId}:whatsapp:group:${cleaned}`; + return `agent:${agentId}:whatsapp:group:${cleaned}`.toLowerCase(); } } if (isSurfaceGroupKey(raw)) { - return `agent:${agentId}:${raw}`; + return `agent:${agentId}:${raw}`.toLowerCase(); } - return `agent:${agentId}:${raw}`; + return `agent:${agentId}:${raw}`.toLowerCase(); } function pickLatestLegacyDirectEntry( diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 95010c02d..58aff29ee 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -17,7 +17,7 @@ function normalizeToken(value: string | undefined | null): string { export function normalizeMainKey(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); - return trimmed ? trimmed : DEFAULT_MAIN_KEY; + return trimmed ? trimmed.toLowerCase() : DEFAULT_MAIN_KEY; } export function toAgentRequestSessionKey(storeKey: string | undefined | null): string | undefined { @@ -35,8 +35,12 @@ export function toAgentStoreSessionKey(params: { if (!raw || raw === DEFAULT_MAIN_KEY) { return buildAgentMainSessionKey({ agentId: params.agentId, mainKey: params.mainKey }); } - if (raw.startsWith("agent:")) return raw; - return `agent:${normalizeAgentId(params.agentId)}:${raw}`; + const lowered = raw.toLowerCase(); + if (lowered.startsWith("agent:")) return lowered; + if (lowered.startsWith("subagent:")) { + return `agent:${normalizeAgentId(params.agentId)}:${lowered}`; + } + return `agent:${normalizeAgentId(params.agentId)}:${lowered}`; } export function resolveAgentIdFromSessionKey(sessionKey: string | undefined | null): string { @@ -48,7 +52,7 @@ export function normalizeAgentId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); if (!trimmed) return DEFAULT_AGENT_ID; // Keep it path-safe + shell-friendly. - if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed; + if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase(); // Best-effort fallback: collapse invalid characters to "-" return ( trimmed @@ -63,7 +67,7 @@ export function normalizeAgentId(value: string | undefined | null): string { export function normalizeAccountId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); if (!trimmed) return DEFAULT_ACCOUNT_ID; - if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed; + if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase(); return ( trimmed .toLowerCase() @@ -106,6 +110,7 @@ export function buildAgentPeerSessionKey(params: { peerId, }); if (linkedPeerId) peerId = linkedPeerId; + peerId = peerId.toLowerCase(); if (dmScope === "per-channel-peer" && peerId) { const channel = (params.channel ?? "").trim().toLowerCase() || "unknown"; return `agent:${normalizeAgentId(params.agentId)}:${channel}:dm:${peerId}`; @@ -119,7 +124,7 @@ export function buildAgentPeerSessionKey(params: { }); } const channel = (params.channel ?? "").trim().toLowerCase() || "unknown"; - const peerId = (params.peerId ?? "").trim() || "unknown"; + const peerId = ((params.peerId ?? "").trim() || "unknown").toLowerCase(); return `agent:${normalizeAgentId(params.agentId)}:${channel}:${peerKind}:${peerId}`; } @@ -163,7 +168,7 @@ export function buildGroupHistoryKey(params: { }): string { const channel = normalizeToken(params.channel) || "unknown"; const accountId = normalizeAccountId(params.accountId); - const peerId = params.peerId.trim() || "unknown"; + const peerId = params.peerId.trim().toLowerCase() || "unknown"; return `${channel}:${accountId}:${params.peerKind}:${peerId}`; } @@ -177,9 +182,10 @@ export function resolveThreadSessionKeys(params: { if (!threadId) { return { sessionKey: params.baseSessionKey, parentSessionKey: undefined }; } + const normalizedThreadId = threadId.toLowerCase(); const useSuffix = params.useSuffix ?? true; const sessionKey = useSuffix - ? `${params.baseSessionKey}:thread:${threadId}` + ? `${params.baseSessionKey}:thread:${normalizedThreadId}` : params.baseSessionKey; return { sessionKey, parentSessionKey: params.parentSessionKey }; } diff --git a/src/slack/monitor.tool-result.threads-top-level-replies-replytomode-is-all.test.ts b/src/slack/monitor.tool-result.threads-top-level-replies-replytomode-is-all.test.ts index 2ace208d9..6825eb4a1 100644 --- a/src/slack/monitor.tool-result.threads-top-level-replies-replytomode-is-all.test.ts +++ b/src/slack/monitor.tool-result.threads-top-level-replies-replytomode-is-all.test.ts @@ -153,8 +153,8 @@ describe("monitorSlackProvider tool results", () => { SessionKey?: string; ParentSessionKey?: string; }; - expect(ctx.SessionKey).toBe("agent:main:slack:channel:C1:thread:111.222"); - expect(ctx.ParentSessionKey).toBe("agent:main:slack:channel:C1"); + expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222"); + expect(ctx.ParentSessionKey).toBe("agent:main:slack:channel:c1"); }); it("injects starter context for thread replies", async () => { @@ -216,7 +216,7 @@ describe("monitorSlackProvider tool results", () => { ThreadStarterBody?: string; ThreadLabel?: string; }; - expect(ctx.SessionKey).toBe("agent:main:slack:channel:C1:thread:111.222"); + expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222"); expect(ctx.ParentSessionKey).toBeUndefined(); expect(ctx.ThreadStarterBody).toContain("starter message"); expect(ctx.ThreadLabel).toContain("Slack thread #general"); @@ -280,7 +280,7 @@ describe("monitorSlackProvider tool results", () => { SessionKey?: string; ParentSessionKey?: string; }; - expect(ctx.SessionKey).toBe("agent:support:slack:channel:C1:thread:111.222"); + expect(ctx.SessionKey).toBe("agent:support:slack:channel:c1:thread:111.222"); expect(ctx.ParentSessionKey).toBeUndefined(); }); diff --git a/src/slack/monitor/context.test.ts b/src/slack/monitor/context.test.ts index d63b1c71a..2811424bb 100644 --- a/src/slack/monitor/context.test.ts +++ b/src/slack/monitor/context.test.ts @@ -56,7 +56,7 @@ describe("resolveSlackSystemEventSessionKey", () => { it("defaults missing channel_type to channel sessions", () => { const ctx = createSlackMonitorContext(baseParams()); expect(ctx.resolveSlackSystemEventSessionKey({ channelId: "C123" })).toBe( - "agent:main:slack:channel:C123", + "agent:main:slack:channel:c123", ); }); }); diff --git a/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts b/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts index ef6edb1fc..e0f51f447 100644 --- a/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts +++ b/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts @@ -48,7 +48,7 @@ describe("prepareSlackMessage sender prefix", () => { logger: { info: vi.fn() }, markMessageSeen: () => false, shouldDropMismatchedSlackEvent: () => false, - resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:C1", + resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:c1", isChannelAllowed: () => true, resolveChannelName: async () => ({ name: "general", diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index e38f00b4f..32900d7a0 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -408,7 +408,8 @@ export function registerSlackMonitorSlashCommands(params: { WasMentioned: true, MessageSid: command.trigger_id, Timestamp: Date.now(), - SessionKey: `agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`, + SessionKey: + `agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`.toLowerCase(), CommandTargetSessionKey: route.sessionKey, AccountId: route.accountId, CommandSource: "native" as const, diff --git a/src/web/auto-reply/session-snapshot.test.ts b/src/web/auto-reply/session-snapshot.test.ts index e6cf013c0..b0000fb72 100644 --- a/src/web/auto-reply/session-snapshot.test.ts +++ b/src/web/auto-reply/session-snapshot.test.ts @@ -14,7 +14,7 @@ describe("getSessionSnapshot", () => { try { const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-snapshot-")); const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:whatsapp:dm:S1"; + const sessionKey = "agent:main:whatsapp:dm:s1"; await saveSessionStore(storePath, { [sessionKey]: {