diff --git a/CHANGELOG.md b/CHANGELOG.md index 20365aa36..2cb464394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf. - Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007. - Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee. +- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow. - Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows. - Skills: add user-invocable skill commands (sanitized + unique), native skill registration gating, and an opt-out for model invocation. - Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 005edf7be..075f4b039 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -11,6 +11,7 @@ Use `session.dmScope` to control how **direct messages** are grouped: - `main` (default): all DMs share the main session for continuity. - `per-peer`: isolate by sender id across channels. - `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes). +Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`. ## Gateway is the source of truth All session state is **owned by the gateway** (the “master” Clawdbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files. @@ -42,6 +43,7 @@ the workspace is writable. See [Memory](/concepts/memory) and - Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation. - `per-peer`: `agent::dm:`. - `per-channel-peer`: `agent:::dm:`. + - If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `` so the same person shares a session across channels. - Group chats isolate state: `agent:::group:` (rooms/channels use `agent:::channel:`). - Telegram forum topics append `:topic:` to the group id for isolation. - Legacy `group:` keys are still recognized for migration. @@ -86,6 +88,9 @@ Send these as standalone messages so they register. session: { scope: "per-sender", // keep group keys separate dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes) + identityLinks: { + alice: ["telegram:123456789", "discord:987654321012345678"] + }, idleMinutes: 120, resetTriggers: ["/new", "/reset"], store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json", diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 49afda5a4..e6e30a597 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2269,6 +2269,9 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto session: { scope: "per-sender", dmScope: "main", + identityLinks: { + alice: ["telegram:123456789", "discord:987654321012345678"] + }, idleMinutes: 60, resetTriggers: ["/new", "/reset"], // Default is already per-agent under ~/.clawdbot/agents//sessions/sessions.json @@ -2297,6 +2300,8 @@ Fields: - `main`: all DMs share the main session for continuity. - `per-peer`: isolate DMs by sender id across channels. - `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes). +- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`. + - Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`. - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5). - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 3106ca8ba..f830bb098 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -133,7 +133,7 @@ By default, Clawdbot routes **all DMs into the main session** so your assistant } ``` -This prevents cross-user context leakage while keeping group chats isolated. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration). +This prevents cross-user context leakage while keeping group chats isolated. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration). ## Allowlists (DM + groups) — terminology diff --git a/src/config/schema.ts b/src/config/schema.ts index 475d02130..25bd2c4d1 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -337,6 +337,8 @@ const FIELD_HELP: Record = { "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", "session.dmScope": 'DM session scoping: "main" keeps continuity; "per-peer" or "per-channel-peer" isolates DM history (recommended for shared inboxes).', + "session.identityLinks": + "Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).", "channels.telegram.configWrites": "Allow Telegram to write config in response to channel events/commands (default: true).", "channels.slack.configWrites": diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 64a503926..3c33f16aa 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -57,6 +57,8 @@ export type SessionConfig = { scope?: SessionScope; /** DM session scoping (default: "main"). */ dmScope?: DmScope; + /** Map platform-prefixed identities (e.g. "telegram:123") to canonical DM peers. */ + identityLinks?: Record; resetTriggers?: string[]; idleMinutes?: number; heartbeatIdleMinutes?: number; diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 8f5f6573b..df6239e05 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -13,6 +13,7 @@ export const SessionSchema = z dmScope: z .union([z.literal("main"), z.literal("per-peer"), z.literal("per-channel-peer")]) .optional(), + identityLinks: z.record(z.string(), z.array(z.string())).optional(), resetTriggers: z.array(z.string()).optional(), idleMinutes: z.number().int().positive().optional(), heartbeatIdleMinutes: z.number().int().positive().optional(), diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index a526ee234..ec4a9cc9a 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -44,6 +44,42 @@ describe("resolveAgentRoute", () => { expect(route.sessionKey).toBe("agent:main:whatsapp:dm:+15551234567"); }); + test("identityLinks collapses per-peer DM sessions across providers", () => { + const cfg: ClawdbotConfig = { + session: { + dmScope: "per-peer", + identityLinks: { + alice: ["telegram:111111111", "discord:222222222222222222"], + }, + }, + }; + const route = resolveAgentRoute({ + cfg, + channel: "telegram", + accountId: null, + peer: { kind: "dm", id: "111111111" }, + }); + expect(route.sessionKey).toBe("agent:main:dm:alice"); + }); + + test("identityLinks applies to per-channel-peer DM sessions", () => { + const cfg: ClawdbotConfig = { + session: { + dmScope: "per-channel-peer", + identityLinks: { + alice: ["telegram:111111111", "discord:222222222222222222"], + }, + }, + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + accountId: null, + peer: { kind: "dm", id: "222222222222222222" }, + }); + expect(route.sessionKey).toBe("agent:main:discord:dm:alice"); + }); + test("peer binding wins over account binding", () => { const cfg: ClawdbotConfig = { bindings: [ diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 318c9ee0c..15d51923b 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -70,6 +70,7 @@ export function buildAgentSessionKey(params: { peer?: RoutePeer | null; /** DM session scope. */ dmScope?: "main" | "per-peer" | "per-channel-peer"; + identityLinks?: Record; }): string { const channel = normalizeToken(params.channel) || "unknown"; const peer = params.peer; @@ -80,6 +81,7 @@ export function buildAgentSessionKey(params: { peerKind: peer?.kind ?? "dm", peerId: peer ? normalizeId(peer.id) || "unknown" : null, dmScope: params.dmScope, + identityLinks: params.identityLinks, }); } @@ -153,6 +155,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR }); const dmScope = input.cfg.session?.dmScope ?? "main"; + const identityLinks = input.cfg.session?.identityLinks; const choose = (agentId: string, matchedBy: ResolvedAgentRoute["matchedBy"]) => { const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId); @@ -165,6 +168,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR channel, peer, dmScope, + identityLinks, }), mainSessionKey: buildAgentMainSessionKey({ agentId: resolvedAgentId, diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 5bb10fbb8..1821b7ad5 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -88,13 +88,23 @@ export function buildAgentPeerSessionKey(params: { channel: string; peerKind?: "dm" | "group" | "channel" | null; peerId?: string | null; + identityLinks?: Record; /** DM session scope. */ dmScope?: "main" | "per-peer" | "per-channel-peer"; }): string { const peerKind = params.peerKind ?? "dm"; if (peerKind === "dm") { const dmScope = params.dmScope ?? "main"; - const peerId = (params.peerId ?? "").trim(); + let peerId = (params.peerId ?? "").trim(); + const linkedPeerId = + dmScope === "main" + ? null + : resolveLinkedPeerId({ + identityLinks: params.identityLinks, + channel: params.channel, + peerId, + }); + if (linkedPeerId) peerId = linkedPeerId; if (dmScope === "per-channel-peer" && peerId) { const channel = (params.channel ?? "").trim().toLowerCase() || "unknown"; return `agent:${normalizeAgentId(params.agentId)}:${channel}:dm:${peerId}`; @@ -112,6 +122,38 @@ export function buildAgentPeerSessionKey(params: { return `agent:${normalizeAgentId(params.agentId)}:${channel}:${peerKind}:${peerId}`; } +function resolveLinkedPeerId(params: { + identityLinks?: Record; + channel: string; + peerId: string; +}): string | null { + const identityLinks = params.identityLinks; + if (!identityLinks) return null; + const peerId = params.peerId.trim(); + if (!peerId) return null; + const candidates = new Set(); + const rawCandidate = normalizeToken(peerId); + if (rawCandidate) candidates.add(rawCandidate); + const channel = normalizeToken(params.channel); + if (channel) { + const scopedCandidate = normalizeToken(`${channel}:${peerId}`); + if (scopedCandidate) candidates.add(scopedCandidate); + } + if (candidates.size === 0) return null; + for (const [canonical, ids] of Object.entries(identityLinks)) { + const canonicalName = canonical.trim(); + if (!canonicalName) continue; + if (!Array.isArray(ids)) continue; + for (const id of ids) { + const normalized = normalizeToken(id); + if (normalized && candidates.has(normalized)) { + return canonicalName; + } + } + } + return null; +} + export function buildGroupHistoryKey(params: { channel: string; accountId?: string | null;