feat: add session.identityLinks for cross-platform DM session linking (#1033)
Co-authored-by: Shadow <shadow@clawd.bot>
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
- 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/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.
|
- 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.
|
- 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.
|
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Use `session.dmScope` to control how **direct messages** are grouped:
|
|||||||
- `main` (default): all DMs share the main session for continuity.
|
- `main` (default): all DMs share the main session for continuity.
|
||||||
- `per-peer`: isolate by sender id across channels.
|
- `per-peer`: isolate by sender id across channels.
|
||||||
- `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes).
|
- `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
|
## 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.
|
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.
|
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
|
||||||
- `per-peer`: `agent:<agentId>:dm:<peerId>`.
|
- `per-peer`: `agent:<agentId>:dm:<peerId>`.
|
||||||
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
|
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
|
||||||
|
- If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `<peerId>` so the same person shares a session across channels.
|
||||||
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
|
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
|
||||||
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
|
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
|
||||||
- Legacy `group:<id>` keys are still recognized for migration.
|
- Legacy `group:<id>` keys are still recognized for migration.
|
||||||
@@ -86,6 +88,9 @@ Send these as standalone messages so they register.
|
|||||||
session: {
|
session: {
|
||||||
scope: "per-sender", // keep group keys separate
|
scope: "per-sender", // keep group keys separate
|
||||||
dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes)
|
dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes)
|
||||||
|
identityLinks: {
|
||||||
|
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||||
|
},
|
||||||
idleMinutes: 120,
|
idleMinutes: 120,
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
||||||
|
|||||||
@@ -2269,6 +2269,9 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
|||||||
session: {
|
session: {
|
||||||
scope: "per-sender",
|
scope: "per-sender",
|
||||||
dmScope: "main",
|
dmScope: "main",
|
||||||
|
identityLinks: {
|
||||||
|
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||||
|
},
|
||||||
idleMinutes: 60,
|
idleMinutes: 60,
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
||||||
@@ -2297,6 +2300,8 @@ Fields:
|
|||||||
- `main`: all DMs share the main session for continuity.
|
- `main`: all DMs share the main session for continuity.
|
||||||
- `per-peer`: isolate DMs by sender id across channels.
|
- `per-peer`: isolate DMs by sender id across channels.
|
||||||
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
|
- `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).
|
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
||||||
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
- `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.
|
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||||
|
|||||||
@@ -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
|
## Allowlists (DM + groups) — terminology
|
||||||
|
|
||||||
|
|||||||
@@ -337,6 +337,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
|
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
|
||||||
"session.dmScope":
|
"session.dmScope":
|
||||||
'DM session scoping: "main" keeps continuity; "per-peer" or "per-channel-peer" isolates DM history (recommended for shared inboxes).',
|
'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":
|
"channels.telegram.configWrites":
|
||||||
"Allow Telegram to write config in response to channel events/commands (default: true).",
|
"Allow Telegram to write config in response to channel events/commands (default: true).",
|
||||||
"channels.slack.configWrites":
|
"channels.slack.configWrites":
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export type SessionConfig = {
|
|||||||
scope?: SessionScope;
|
scope?: SessionScope;
|
||||||
/** DM session scoping (default: "main"). */
|
/** DM session scoping (default: "main"). */
|
||||||
dmScope?: DmScope;
|
dmScope?: DmScope;
|
||||||
|
/** Map platform-prefixed identities (e.g. "telegram:123") to canonical DM peers. */
|
||||||
|
identityLinks?: Record<string, string[]>;
|
||||||
resetTriggers?: string[];
|
resetTriggers?: string[];
|
||||||
idleMinutes?: number;
|
idleMinutes?: number;
|
||||||
heartbeatIdleMinutes?: number;
|
heartbeatIdleMinutes?: number;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const SessionSchema = z
|
|||||||
dmScope: z
|
dmScope: z
|
||||||
.union([z.literal("main"), z.literal("per-peer"), z.literal("per-channel-peer")])
|
.union([z.literal("main"), z.literal("per-peer"), z.literal("per-channel-peer")])
|
||||||
.optional(),
|
.optional(),
|
||||||
|
identityLinks: z.record(z.string(), z.array(z.string())).optional(),
|
||||||
resetTriggers: z.array(z.string()).optional(),
|
resetTriggers: z.array(z.string()).optional(),
|
||||||
idleMinutes: z.number().int().positive().optional(),
|
idleMinutes: z.number().int().positive().optional(),
|
||||||
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
||||||
|
|||||||
@@ -44,6 +44,42 @@ describe("resolveAgentRoute", () => {
|
|||||||
expect(route.sessionKey).toBe("agent:main:whatsapp:dm:+15551234567");
|
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", () => {
|
test("peer binding wins over account binding", () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
bindings: [
|
bindings: [
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function buildAgentSessionKey(params: {
|
|||||||
peer?: RoutePeer | null;
|
peer?: RoutePeer | null;
|
||||||
/** DM session scope. */
|
/** DM session scope. */
|
||||||
dmScope?: "main" | "per-peer" | "per-channel-peer";
|
dmScope?: "main" | "per-peer" | "per-channel-peer";
|
||||||
|
identityLinks?: Record<string, string[]>;
|
||||||
}): string {
|
}): string {
|
||||||
const channel = normalizeToken(params.channel) || "unknown";
|
const channel = normalizeToken(params.channel) || "unknown";
|
||||||
const peer = params.peer;
|
const peer = params.peer;
|
||||||
@@ -80,6 +81,7 @@ export function buildAgentSessionKey(params: {
|
|||||||
peerKind: peer?.kind ?? "dm",
|
peerKind: peer?.kind ?? "dm",
|
||||||
peerId: peer ? normalizeId(peer.id) || "unknown" : null,
|
peerId: peer ? normalizeId(peer.id) || "unknown" : null,
|
||||||
dmScope: params.dmScope,
|
dmScope: params.dmScope,
|
||||||
|
identityLinks: params.identityLinks,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
|
|||||||
});
|
});
|
||||||
|
|
||||||
const dmScope = input.cfg.session?.dmScope ?? "main";
|
const dmScope = input.cfg.session?.dmScope ?? "main";
|
||||||
|
const identityLinks = input.cfg.session?.identityLinks;
|
||||||
|
|
||||||
const choose = (agentId: string, matchedBy: ResolvedAgentRoute["matchedBy"]) => {
|
const choose = (agentId: string, matchedBy: ResolvedAgentRoute["matchedBy"]) => {
|
||||||
const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId);
|
const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId);
|
||||||
@@ -165,6 +168,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
|
|||||||
channel,
|
channel,
|
||||||
peer,
|
peer,
|
||||||
dmScope,
|
dmScope,
|
||||||
|
identityLinks,
|
||||||
}),
|
}),
|
||||||
mainSessionKey: buildAgentMainSessionKey({
|
mainSessionKey: buildAgentMainSessionKey({
|
||||||
agentId: resolvedAgentId,
|
agentId: resolvedAgentId,
|
||||||
|
|||||||
@@ -88,13 +88,23 @@ export function buildAgentPeerSessionKey(params: {
|
|||||||
channel: string;
|
channel: string;
|
||||||
peerKind?: "dm" | "group" | "channel" | null;
|
peerKind?: "dm" | "group" | "channel" | null;
|
||||||
peerId?: string | null;
|
peerId?: string | null;
|
||||||
|
identityLinks?: Record<string, string[]>;
|
||||||
/** DM session scope. */
|
/** DM session scope. */
|
||||||
dmScope?: "main" | "per-peer" | "per-channel-peer";
|
dmScope?: "main" | "per-peer" | "per-channel-peer";
|
||||||
}): string {
|
}): string {
|
||||||
const peerKind = params.peerKind ?? "dm";
|
const peerKind = params.peerKind ?? "dm";
|
||||||
if (peerKind === "dm") {
|
if (peerKind === "dm") {
|
||||||
const dmScope = params.dmScope ?? "main";
|
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) {
|
if (dmScope === "per-channel-peer" && peerId) {
|
||||||
const channel = (params.channel ?? "").trim().toLowerCase() || "unknown";
|
const channel = (params.channel ?? "").trim().toLowerCase() || "unknown";
|
||||||
return `agent:${normalizeAgentId(params.agentId)}:${channel}:dm:${peerId}`;
|
return `agent:${normalizeAgentId(params.agentId)}:${channel}:dm:${peerId}`;
|
||||||
@@ -112,6 +122,38 @@ export function buildAgentPeerSessionKey(params: {
|
|||||||
return `agent:${normalizeAgentId(params.agentId)}:${channel}:${peerKind}:${peerId}`;
|
return `agent:${normalizeAgentId(params.agentId)}:${channel}:${peerKind}:${peerId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveLinkedPeerId(params: {
|
||||||
|
identityLinks?: Record<string, string[]>;
|
||||||
|
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<string>();
|
||||||
|
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: {
|
export function buildGroupHistoryKey(params: {
|
||||||
channel: string;
|
channel: string;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user