diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb4779559a..7afcc35b405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Agents/compaction: resolve `agents.defaults.compaction.model` consistently for manual `/compact` and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg +- Channels/session routing: move provider-specific session conversation grammar into plugin-owned session-key surfaces, preserving Telegram topic routing and Feishu scoped inheritance across bootstrap, model override, restart, and tool-policy paths. ## 2026.3.31 diff --git a/docs/.generated/plugin-sdk-api-baseline.json b/docs/.generated/plugin-sdk-api-baseline.json index 1d020ab4e4a..ce70f5a6de9 100644 --- a/docs/.generated/plugin-sdk-api-baseline.json +++ b/docs/.generated/plugin-sdk-api-baseline.json @@ -154,7 +154,7 @@ "exportName": "ChannelMessageActionAdapter", "kind": "type", "source": { - "line": 529, + "line": 556, "path": "src/channels/plugins/types.core.ts" } }, @@ -163,7 +163,7 @@ "exportName": "ChannelMessageActionContext", "kind": "type", "source": { - "line": 493, + "line": 520, "path": "src/channels/plugins/types.core.ts" } }, @@ -1170,7 +1170,7 @@ "exportName": "BaseProbeResult", "kind": "type", "source": { - "line": 572, + "line": 599, "path": "src/channels/plugins/types.core.ts" } }, @@ -1179,7 +1179,7 @@ "exportName": "BaseTokenResolution", "kind": "type", "source": { - "line": 578, + "line": 605, "path": "src/channels/plugins/types.core.ts" } }, @@ -1224,7 +1224,7 @@ "exportName": "ChannelMessageActionAdapter", "kind": "type", "source": { - "line": 529, + "line": 556, "path": "src/channels/plugins/types.core.ts" } }, @@ -1233,7 +1233,7 @@ "exportName": "ChannelMessageActionContext", "kind": "type", "source": { - "line": 493, + "line": 520, "path": "src/channels/plugins/types.core.ts" } }, @@ -1752,7 +1752,7 @@ "exportName": "BaseProbeResult", "kind": "type", "source": { - "line": 572, + "line": 599, "path": "src/channels/plugins/types.core.ts" } }, @@ -1761,7 +1761,7 @@ "exportName": "BaseTokenResolution", "kind": "type", "source": { - "line": 578, + "line": 605, "path": "src/channels/plugins/types.core.ts" } }, @@ -1797,7 +1797,7 @@ "exportName": "ChannelAgentPromptAdapter", "kind": "type", "source": { - "line": 466, + "line": 493, "path": "src/channels/plugins/types.core.ts" } }, @@ -1977,7 +1977,7 @@ "exportName": "ChannelDirectoryEntry", "kind": "type", "source": { - "line": 480, + "line": 507, "path": "src/channels/plugins/types.core.ts" } }, @@ -1986,7 +1986,7 @@ "exportName": "ChannelDirectoryEntryKind", "kind": "type", "source": { - "line": 478, + "line": 505, "path": "src/channels/plugins/types.core.ts" } }, @@ -2130,7 +2130,7 @@ "exportName": "ChannelMessageActionAdapter", "kind": "type", "source": { - "line": 529, + "line": 556, "path": "src/channels/plugins/types.core.ts" } }, @@ -2139,7 +2139,7 @@ "exportName": "ChannelMessageActionContext", "kind": "type", "source": { - "line": 493, + "line": 520, "path": "src/channels/plugins/types.core.ts" } }, @@ -2274,7 +2274,7 @@ "exportName": "ChannelPollContext", "kind": "type", "source": { - "line": 560, + "line": 587, "path": "src/channels/plugins/types.core.ts" } }, @@ -2283,7 +2283,7 @@ "exportName": "ChannelPollResult", "kind": "type", "source": { - "line": 551, + "line": 578, "path": "src/channels/plugins/types.core.ts" } }, @@ -2427,7 +2427,7 @@ "exportName": "ChannelToolSend", "kind": "type", "source": { - "line": 522, + "line": 549, "path": "src/channels/plugins/types.core.ts" } }, @@ -2683,7 +2683,7 @@ "exportName": "buildCommandsMessage", "kind": "function", "source": { - "line": 1075, + "line": 1076, "path": "src/auto-reply/status.ts" } }, @@ -2692,7 +2692,7 @@ "exportName": "buildCommandsMessagePaginated", "kind": "function", "source": { - "line": 1084, + "line": 1085, "path": "src/auto-reply/status.ts" } }, @@ -2728,7 +2728,7 @@ "exportName": "buildHelpMessage", "kind": "function", "source": { - "line": 870, + "line": 871, "path": "src/auto-reply/status.ts" } }, @@ -3738,7 +3738,7 @@ "exportName": "ChannelMessageActionContext", "kind": "type", "source": { - "line": 493, + "line": 520, "path": "src/channels/plugins/types.core.ts" } }, diff --git a/docs/.generated/plugin-sdk-api-baseline.jsonl b/docs/.generated/plugin-sdk-api-baseline.jsonl index 814f47c7ff1..6f0dbbe4559 100644 --- a/docs/.generated/plugin-sdk-api-baseline.jsonl +++ b/docs/.generated/plugin-sdk-api-baseline.jsonl @@ -15,8 +15,8 @@ {"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":674,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelGatewayContext = ChannelGatewayContext;","entrypoint":"index","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":271,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelId = ChannelId;","entrypoint":"index","exportName":"ChannelId","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":14,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"index","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":529,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"index","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":493,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"index","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":556,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"index","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":520,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageActionName = \"send\" | \"broadcast\" | \"poll\" | \"poll-vote\" | \"react\" | \"reactions\" | \"read\" | \"edit\" | \"unsend\" | \"reply\" | \"sendWithEffect\" | \"renameGroup\" | \"setGroupIcon\" | \"addParticipant\" | \"removeParticipant\" | \"leaveGroup\" | \"sendAttachment\" | \"delete\" | \"pin\" | \"unpin\" | \"list-pins\" | \"permissions\" | \"thread-create\" | \"thread-list\" | \"thread-reply\" | \"search\" | \"sticker\" | \"sticker-search\" | \"member-info\" | \"role-info\" | \"emoji-list\" | \"emoji-upload\" | \"sticker-upload\" | \"role-add\" | \"role-remove\" | \"channel-info\" | \"channel-list\" | \"channel-create\" | \"channel-edit\" | \"channel-delete\" | \"channel-move\" | \"category-create\" | \"category-edit\" | \"category-delete\" | \"topic-create\" | \"topic-edit\" | \"voice-status\" | \"event-list\" | \"event-create\" | \"timeout\" | \"kick\" | \"ban\" | \"set-profile\" | \"set-presence\" | \"download-file\" | \"upload-file\";","entrypoint":"index","exportName":"ChannelMessageActionName","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":6,"sourcePath":"src/channels/plugins/types.ts"} {"declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"index","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":77,"sourcePath":"src/channels/plugins/types.plugin.ts"} {"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"index","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":63,"sourcePath":"src/channels/plugins/types.adapters.ts"} @@ -127,14 +127,14 @@ {"declaration":"export const ToolPolicySchema: z.ZodOptional>; alsoAllow: z.ZodOptional>; deny: z.ZodOptional>; }, z.core.$strict>>;","entrypoint":"channel-config-schema","exportName":"ToolPolicySchema","importSpecifier":"openclaw/plugin-sdk/channel-config-schema","kind":"const","recordType":"export","sourceLine":253,"sourcePath":"src/config/zod-schema.agent-runtime.ts"} {"declaration":"export const WhatsAppConfigSchema: z.ZodObject<{ enabled: z.ZodOptional; capabilities: z.ZodOptional>; markdown: z.ZodOptional>; }, z.core.$strict>>; configWrites: z.ZodOptional; sendReadReceipts: z.ZodOptional; messagePrefix: z.ZodOptional; responsePrefix: z.ZodOptional; dmPolicy: z.ZodDefault>>; selfChatMode: z.ZodOptional; allowFrom: z.ZodOptional>; defaultTo: z.ZodOptional; groupAllowFrom: z.ZodOptional>; groupPolicy: z.ZodDefault>>; historyLimit: z.ZodOptional; dmHistoryLimit: z.ZodOptional; dms: z.ZodOptional; }, z.core.$strict>>>>; textChunkLimit: z.ZodOptional; chunkMode: z.ZodOptional>; blockStreaming: z.ZodOptional; blockStreamingCoalesce: z.ZodOptional; maxChars: z.ZodOptional; idleMs: z.ZodOptional; }, z.core.$strict>>; groups: z.ZodOptional; tools: z.ZodOptional>; alsoAllow: z.ZodOptional>; deny: z.ZodOptional>; }, z.core.$strict>>; toolsBySender: z.ZodOptional>; alsoAllow: z.ZodOptional>; deny: z.ZodOptional>; }, z.core.$strict>>>>; }, z.core.$strict>>>>; ackReaction: z.ZodOptional; direct: z.ZodDefault>; group: z.ZodDefault>>; }, z.core.$strict>>; debounceMs: z.ZodDefault>; heartbeat: z.ZodOptional; showAlerts: z.ZodOptional; useIndicator: z.ZodOptional; }, z.core.$strict>>; healthMonitor: z.ZodOptional; }, z.core.$strict>>; accounts: z.ZodOptional>; markdown: z.ZodOptional>; }, z.core.$strict>>; configWrites: z.ZodOptional; sendReadReceipts: z.ZodOptional; messagePrefix: z.ZodOptional; responsePrefix: z.ZodOptional; dmPolicy: z.ZodDefault>>; selfChatMode: z.ZodOptional; allowFrom: z.ZodOptional>; defaultTo: z.ZodOptional; groupAllowFrom: z.ZodOptional>; groupPolicy: z.ZodDefault>>; historyLimit: z.ZodOptional; dmHistoryLimit: z.ZodOptional; dms: z.ZodOptional; }, z.core.$strict>>>>; textChunkLimit: z.ZodOptional; chunkMode: z.ZodOptional>; blockStreaming: z.ZodOptional; blockStreamingCoalesce: z.ZodOptional; maxChars: z.ZodOptional; idleMs: z.ZodOptional; }, z.core.$strict>>; groups: z.ZodOptional; tools: z.ZodOptional>; alsoAllow: z.ZodOptional>; deny: z.ZodOptional>; }, z.core.$strict>>; toolsBySender: z.ZodOptional>; alsoAllow: z.ZodOptional>; deny: z.ZodOptional>; }, z.core.$strict>>>>; }, z.core.$strict>>>>; ackReaction: z.ZodOptional; direct: z.ZodDefault>; group: z.ZodDefault>>; }, z.core.$strict>>; debounceMs: z.ZodDefault>; heartbeat: z.ZodOptional; showAlerts: z.ZodOptional; useIndicator: z.ZodOptional; }, z.core.$strict>>; healthMonitor: z.ZodOptional; }, z.core.$strict>>; name: z.ZodOptional; enabled: z.ZodOptional; authDir: z.ZodOptional; mediaMaxMb: z.ZodOptional; }, z.core.$strict>>>>; defaultAccount: z.ZodOptional; mediaMaxMb: z.ZodDefault>; actions: z.ZodOptional; sendMessage: z.ZodOptional; polls: z.ZodOptional; }, z.core.$strict>>; }, z.core.$strict>;","entrypoint":"channel-config-schema","exportName":"WhatsAppConfigSchema","importSpecifier":"openclaw/plugin-sdk/channel-config-schema","kind":"const","recordType":"export","sourceLine":119,"sourcePath":"src/config/zod-schema.providers-whatsapp.ts"} {"category":"channel","entrypoint":"channel-contract","importSpecifier":"openclaw/plugin-sdk/channel-contract","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/channel-contract.ts"} -{"declaration":"export type BaseProbeResult = BaseProbeResult;","entrypoint":"channel-contract","exportName":"BaseProbeResult","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":572,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type BaseTokenResolution = BaseTokenResolution;","entrypoint":"channel-contract","exportName":"BaseTokenResolution","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":578,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type BaseProbeResult = BaseProbeResult;","entrypoint":"channel-contract","exportName":"BaseProbeResult","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":599,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type BaseTokenResolution = BaseTokenResolution;","entrypoint":"channel-contract","exportName":"BaseTokenResolution","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":605,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"channel-contract","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":147,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAgentTool = ChannelAgentTool;","entrypoint":"channel-contract","exportName":"ChannelAgentTool","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":19,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-contract","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":662,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelGroupContext = ChannelGroupContext;","entrypoint":"channel-contract","exportName":"ChannelGroupContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":219,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"channel-contract","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":529,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"channel-contract","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":493,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"channel-contract","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":556,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"channel-contract","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":520,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageActionDiscoveryContext = ChannelMessageActionDiscoveryContext;","entrypoint":"channel-contract","exportName":"ChannelMessageActionDiscoveryContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageActionName = \"send\" | \"broadcast\" | \"poll\" | \"poll-vote\" | \"react\" | \"reactions\" | \"read\" | \"edit\" | \"unsend\" | \"reply\" | \"sendWithEffect\" | \"renameGroup\" | \"setGroupIcon\" | \"addParticipant\" | \"removeParticipant\" | \"leaveGroup\" | \"sendAttachment\" | \"delete\" | \"pin\" | \"unpin\" | \"list-pins\" | \"permissions\" | \"thread-create\" | \"thread-list\" | \"thread-reply\" | \"search\" | \"sticker\" | \"sticker-search\" | \"member-info\" | \"role-info\" | \"emoji-list\" | \"emoji-upload\" | \"sticker-upload\" | \"role-add\" | \"role-remove\" | \"channel-info\" | \"channel-list\" | \"channel-create\" | \"channel-edit\" | \"channel-delete\" | \"channel-move\" | \"category-create\" | \"category-edit\" | \"category-delete\" | \"topic-create\" | \"topic-edit\" | \"voice-status\" | \"event-list\" | \"event-create\" | \"timeout\" | \"kick\" | \"ban\" | \"set-profile\" | \"set-presence\" | \"download-file\" | \"upload-file\";","entrypoint":"channel-contract","exportName":"ChannelMessageActionName","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":6,"sourcePath":"src/channels/plugins/types.ts"} {"declaration":"export type ChannelMessageToolDiscovery = ChannelMessageToolDiscovery;","entrypoint":"channel-contract","exportName":"ChannelMessageToolDiscovery","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":57,"sourcePath":"src/channels/plugins/types.core.ts"} @@ -191,12 +191,12 @@ {"declaration":"export const isWhatsAppGroupJid: (value: string) => boolean;","entrypoint":"channel-runtime","exportName":"isWhatsAppGroupJid","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"const","recordType":"export","sourceLine":13,"sourcePath":"src/plugin-sdk/whatsapp-targets.ts"} {"declaration":"export const isWhatsAppUserTarget: (value: string) => boolean;","entrypoint":"channel-runtime","exportName":"isWhatsAppUserTarget","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"const","recordType":"export","sourceLine":15,"sourcePath":"src/plugin-sdk/whatsapp-targets.ts"} {"declaration":"export const normalizeWhatsAppTarget: (value: string) => string | null;","entrypoint":"channel-runtime","exportName":"normalizeWhatsAppTarget","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"const","recordType":"export","sourceLine":17,"sourcePath":"src/plugin-sdk/whatsapp-targets.ts"} -{"declaration":"export type BaseProbeResult = BaseProbeResult;","entrypoint":"channel-runtime","exportName":"BaseProbeResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":572,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type BaseTokenResolution = BaseTokenResolution;","entrypoint":"channel-runtime","exportName":"BaseTokenResolution","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":578,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type BaseProbeResult = BaseProbeResult;","entrypoint":"channel-runtime","exportName":"BaseProbeResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":599,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type BaseTokenResolution = BaseTokenResolution;","entrypoint":"channel-runtime","exportName":"BaseTokenResolution","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":605,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"channel-runtime","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":147,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAccountState = ChannelAccountState;","entrypoint":"channel-runtime","exportName":"ChannelAccountState","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":110,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelActionAvailabilityState = ChannelActionAvailabilityState;","entrypoint":"channel-runtime","exportName":"ChannelActionAvailabilityState","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelAgentPromptAdapter = ChannelAgentPromptAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAgentPromptAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":466,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelAgentPromptAdapter = ChannelAgentPromptAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAgentPromptAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":493,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAgentTool = ChannelAgentTool;","entrypoint":"channel-runtime","exportName":"ChannelAgentTool","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":19,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAgentToolFactory = ChannelAgentToolFactory;","entrypoint":"channel-runtime","exportName":"ChannelAgentToolFactory","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":24,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAllowlistAdapter = ChannelAllowlistAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAllowlistAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":597,"sourcePath":"src/channels/plugins/types.adapters.ts"} @@ -216,8 +216,8 @@ {"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":674,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelConversationBindingSupport = ChannelConversationBindingSupport;","entrypoint":"channel-runtime","exportName":"ChannelConversationBindingSupport","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":690,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelDirectoryAdapter = ChannelDirectoryAdapter;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":454,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelDirectoryEntry = ChannelDirectoryEntry;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntry","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":480,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelDirectoryEntryKind = ChannelDirectoryEntryKind;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntryKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":478,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelDirectoryEntry = ChannelDirectoryEntry;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntry","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":507,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelDirectoryEntryKind = ChannelDirectoryEntryKind;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntryKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":505,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelElevatedAdapter = ChannelElevatedAdapter;","entrypoint":"channel-runtime","exportName":"ChannelElevatedAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":485,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelGatewayAdapter = ChannelGatewayAdapter;","entrypoint":"channel-runtime","exportName":"ChannelGatewayAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":379,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelGatewayContext = ChannelGatewayContext;","entrypoint":"channel-runtime","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":271,"sourcePath":"src/channels/plugins/types.adapters.ts"} @@ -233,8 +233,8 @@ {"declaration":"export type ChannelLogoutResult = ChannelLogoutResult;","entrypoint":"channel-runtime","exportName":"ChannelLogoutResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":344,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelLogSink = ChannelLogSink;","entrypoint":"channel-runtime","exportName":"ChannelLogSink","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":212,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMentionAdapter = ChannelMentionAdapter;","entrypoint":"channel-runtime","exportName":"ChannelMentionAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":263,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"channel-runtime","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":529,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"channel-runtime","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":493,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"channel-runtime","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":556,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"channel-runtime","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":520,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageActionDiscoveryContext = ChannelMessageActionDiscoveryContext;","entrypoint":"channel-runtime","exportName":"ChannelMessageActionDiscoveryContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageActionName = \"send\" | \"broadcast\" | \"poll\" | \"poll-vote\" | \"react\" | \"reactions\" | \"read\" | \"edit\" | \"unsend\" | \"reply\" | \"sendWithEffect\" | \"renameGroup\" | \"setGroupIcon\" | \"addParticipant\" | \"removeParticipant\" | \"leaveGroup\" | \"sendAttachment\" | \"delete\" | \"pin\" | \"unpin\" | \"list-pins\" | \"permissions\" | \"thread-create\" | \"thread-list\" | \"thread-reply\" | \"search\" | \"sticker\" | \"sticker-search\" | \"member-info\" | \"role-info\" | \"emoji-list\" | \"emoji-upload\" | \"sticker-upload\" | \"role-add\" | \"role-remove\" | \"channel-info\" | \"channel-list\" | \"channel-create\" | \"channel-edit\" | \"channel-delete\" | \"channel-move\" | \"category-create\" | \"category-edit\" | \"category-delete\" | \"topic-create\" | \"topic-edit\" | \"voice-status\" | \"event-list\" | \"event-create\" | \"timeout\" | \"kick\" | \"ban\" | \"set-profile\" | \"set-presence\" | \"download-file\" | \"upload-file\";","entrypoint":"channel-runtime","exportName":"ChannelMessageActionName","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":6,"sourcePath":"src/channels/plugins/types.ts"} {"declaration":"export type ChannelMessageCapability = \"interactive\" | \"buttons\" | \"cards\" | \"components\" | \"blocks\";","entrypoint":"channel-runtime","exportName":"ChannelMessageCapability","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":9,"sourcePath":"src/channels/plugins/message-capabilities.ts"} @@ -249,8 +249,8 @@ {"declaration":"export type ChannelOutboundTargetRef = ChannelOutboundTargetRef;","entrypoint":"channel-runtime","exportName":"ChannelOutboundTargetRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":164,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelPairingAdapter = ChannelPairingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelPairingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":368,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"channel-runtime","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":77,"sourcePath":"src/channels/plugins/types.plugin.ts"} -{"declaration":"export type ChannelPollContext = ChannelPollContext;","entrypoint":"channel-runtime","exportName":"ChannelPollContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":560,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelPollResult = ChannelPollResult;","entrypoint":"channel-runtime","exportName":"ChannelPollResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":551,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelPollContext = ChannelPollContext;","entrypoint":"channel-runtime","exportName":"ChannelPollContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":587,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelPollResult = ChannelPollResult;","entrypoint":"channel-runtime","exportName":"ChannelPollResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":578,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelResolveKind = ChannelResolveKind;","entrypoint":"channel-runtime","exportName":"ChannelResolveKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":465,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelResolverAdapter = ChannelResolverAdapter;","entrypoint":"channel-runtime","exportName":"ChannelResolverAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":475,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelResolveResult = ChannelResolveResult;","entrypoint":"channel-runtime","exportName":"ChannelResolveResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":467,"sourcePath":"src/channels/plugins/types.adapters.ts"} @@ -266,7 +266,7 @@ {"declaration":"export type ChannelThreadingAdapter = ChannelThreadingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelThreadingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":325,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelThreadingContext = ChannelThreadingContext;","entrypoint":"channel-runtime","exportName":"ChannelThreadingContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":368,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelThreadingToolContext = ChannelThreadingToolContext;","entrypoint":"channel-runtime","exportName":"ChannelThreadingToolContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":382,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelToolSend = ChannelToolSend;","entrypoint":"channel-runtime","exportName":"ChannelToolSend","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":522,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelToolSend = ChannelToolSend;","entrypoint":"channel-runtime","exportName":"ChannelToolSend","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":549,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChatType = ChatType;","entrypoint":"channel-runtime","exportName":"ChatType","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/channels/chat-type.ts"} {"declaration":"export type CreateTypingCallbacksParams = CreateTypingCallbacksParams;","entrypoint":"channel-runtime","exportName":"CreateTypingCallbacksParams","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":11,"sourcePath":"src/channels/typing.ts"} {"declaration":"export type HeartbeatEventPayload = HeartbeatEventPayload;","entrypoint":"channel-runtime","exportName":"HeartbeatEventPayload","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":6,"sourcePath":"src/infra/heartbeat-events.ts"} @@ -294,12 +294,12 @@ {"declaration":"export type ChannelSetupWizard = ChannelSetupWizard;","entrypoint":"channel-setup","exportName":"ChannelSetupWizard","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":247,"sourcePath":"src/channels/plugins/setup-wizard.ts"} {"declaration":"export type OptionalChannelSetupSurface = OptionalChannelSetupSurface;","entrypoint":"channel-setup","exportName":"OptionalChannelSetupSurface","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":29,"sourcePath":"src/plugin-sdk/channel-setup.ts"} {"category":"channel","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/command-auth.ts"} -{"declaration":"export function buildCommandsMessage(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandsMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":1075,"sourcePath":"src/auto-reply/status.ts"} -{"declaration":"export function buildCommandsMessagePaginated(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): CommandsMessageResult;","entrypoint":"command-auth","exportName":"buildCommandsMessagePaginated","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":1084,"sourcePath":"src/auto-reply/status.ts"} +{"declaration":"export function buildCommandsMessage(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandsMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":1076,"sourcePath":"src/auto-reply/status.ts"} +{"declaration":"export function buildCommandsMessagePaginated(cfg?: OpenClawConfig | undefined, skillCommands?: SkillCommandSpec[] | undefined, options?: CommandsMessageOptions | undefined): CommandsMessageResult;","entrypoint":"command-auth","exportName":"buildCommandsMessagePaginated","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":1085,"sourcePath":"src/auto-reply/status.ts"} {"declaration":"export function buildCommandsPaginationKeyboard(currentPage: number, totalPages: number, agentId?: string | undefined): { text: string; callback_data: string; }[][];","entrypoint":"command-auth","exportName":"buildCommandsPaginationKeyboard","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":175,"sourcePath":"src/auto-reply/reply/commands-info.ts"} {"declaration":"export function buildCommandText(commandName: string, args?: string | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandText","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":199,"sourcePath":"src/auto-reply/commands-registry.ts"} {"declaration":"export function buildCommandTextFromArgs(command: ChatCommandDefinition, args?: CommandArgs | undefined): string;","entrypoint":"command-auth","exportName":"buildCommandTextFromArgs","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":291,"sourcePath":"src/auto-reply/commands-registry.ts"} -{"declaration":"export function buildHelpMessage(cfg?: OpenClawConfig | undefined): string;","entrypoint":"command-auth","exportName":"buildHelpMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":870,"sourcePath":"src/auto-reply/status.ts"} +{"declaration":"export function buildHelpMessage(cfg?: OpenClawConfig | undefined): string;","entrypoint":"command-auth","exportName":"buildHelpMessage","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":871,"sourcePath":"src/auto-reply/status.ts"} {"declaration":"export function buildModelsProviderData(cfg: OpenClawConfig, agentId?: string | undefined): Promise;","entrypoint":"command-auth","exportName":"buildModelsProviderData","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":39,"sourcePath":"src/auto-reply/reply/commands-models.ts"} {"declaration":"export function createPreCryptoDirectDmAuthorizer(params: { resolveAccess: (senderId: string) => Promise>; issuePairingChallenge?: ((params: { ...; }) => Promise<...>) | undefined; onBlocked?: ((params: { ...; }) => void) | undefined; }): (input: { ...; }) => Promise<...>;","entrypoint":"command-auth","exportName":"createPreCryptoDirectDmAuthorizer","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":105,"sourcePath":"src/plugin-sdk/direct-dm.ts"} {"declaration":"export function findCommandByNativeName(name: string, provider?: string | undefined): ChatCommandDefinition | undefined;","entrypoint":"command-auth","exportName":"findCommandByNativeName","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":187,"sourcePath":"src/auto-reply/commands-registry.ts"} @@ -411,7 +411,7 @@ {"declaration":"export const DEFAULT_SECRET_FILE_MAX_BYTES: number;","entrypoint":"core","exportName":"DEFAULT_SECRET_FILE_MAX_BYTES","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export","sourceLine":5,"sourcePath":"src/infra/secret-file.ts"} {"declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"core","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":9,"sourcePath":"src/agents/tools/common.ts"} {"declaration":"export type ChannelConfigUiHint = ChannelConfigUiHint;","entrypoint":"core","exportName":"ChannelConfigUiHint","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":38,"sourcePath":"src/channels/plugins/types.plugin.ts"} -{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"core","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":493,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"core","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":520,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessagingAdapter = ChannelMessagingAdapter;","entrypoint":"core","exportName":"ChannelMessagingAdapter","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":398,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelOutboundSessionRoute = ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"ChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":312,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelOutboundSessionRouteParams = { cfg: OpenClawConfig; agentId: string; accountId?: string | null; target: string; resolvedTarget?: { to: string; kind: import(\"../channels/plugins/types.core.js\").ChannelDirectoryEntryKind | \"channel\"; display?: string; source: \"normalized\" | \"directory\"; }; replyToId?: string | null; threadId?: string | number | null;};","entrypoint":"core","exportName":"ChannelOutboundSessionRouteParams","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":159,"sourcePath":"src/plugin-sdk/core.ts"} diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index ba47a00d4df..ac574fb9b5d 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -159,6 +159,8 @@ The current boundary is: bookkeeping, and execution dispatch - channel plugins own scoped action discovery, capability discovery, and any channel-specific schema fragments +- channel plugins own provider-specific session conversation grammar, such as + how conversation ids encode thread ids or inherit from parent conversations - channel plugins execute the final action through their action adapter For channel plugins, the SDK surface is diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 27cbc6009ad..a8b7baaa5a2 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -28,11 +28,31 @@ shared `message` tool in core. Your plugin owns: - **Config** — account resolution and setup wizard - **Security** — DM policy and allowlists - **Pairing** — DM approval flow +- **Session grammar** — how provider-specific conversation ids map to base chats, thread ids, and parent fallbacks - **Outbound** — sending text, media, and polls to the platform - **Threading** — how replies are threaded -Core owns the shared message tool, prompt wiring, session bookkeeping, and -dispatch. +Core owns the shared message tool, prompt wiring, the outer session-key shape, +generic `:thread:` bookkeeping, and dispatch. + +If your platform stores extra scope inside conversation ids, keep that parsing +in the plugin with `messaging.resolveSessionConversation(...)`. That is the +canonical hook for mapping `rawId` to the base conversation id, optional thread +id, explicit `baseConversationId`, and any `parentConversationCandidates`. +When you return `parentConversationCandidates`, keep them ordered from the +narrowest parent to the broadest/base conversation. + +Bundled plugins that need the same parsing before the channel registry boots +can also expose a top-level `session-key-api.ts` file with a matching +`resolveSessionConversation(...)` export. Core uses that bootstrap-safe surface +only when the runtime plugin registry is not available yet. + +`messaging.resolveParentConversationCandidates(...)` remains available as a +legacy compatibility fallback when a plugin only needs parent fallbacks on top +of the generic/raw id. If both hooks exist, core uses +`resolveSessionConversation(...).parentConversationCandidates` first and only +falls back to `resolveParentConversationCandidates(...)` when the canonical hook +omits them. ## Approvals and channel capabilities diff --git a/extensions/discord/src/monitor/threading.ts b/extensions/discord/src/monitor/threading.ts index 24ead3e8c68..c7dbb3df4ba 100644 --- a/extensions/discord/src/monitor/threading.ts +++ b/extensions/discord/src/monitor/threading.ts @@ -501,6 +501,7 @@ function resolveDiscordThreadTitleModelRef(params: { cfg: params.cfg, channel, groupId: params.threadId, + groupChatType: "channel", groupChannel, groupSubject: groupChannel, parentSessionKey, diff --git a/extensions/feishu/session-key-api.ts b/extensions/feishu/session-key-api.ts new file mode 100644 index 00000000000..53cff005b63 --- /dev/null +++ b/extensions/feishu/session-key-api.ts @@ -0,0 +1 @@ +export { resolveFeishuSessionConversation as resolveSessionConversation } from "./src/session-conversation.js"; diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 852d42e3f29..9e36dce3940 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -183,6 +183,46 @@ describe("feishuPlugin.pairing.notifyApproval", () => { }); }); +describe("feishuPlugin messaging", () => { + beforeEach(async () => { + vi.resetModules(); + ({ feishuPlugin } = await import("./channel.js")); + }); + + it("owns sender/topic session inheritance candidates", () => { + expect( + feishuPlugin.messaging?.resolveSessionConversation?.({ + kind: "group", + rawId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + }), + ).toEqual({ + id: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + baseConversationId: "oc_group_chat", + parentConversationCandidates: ["oc_group_chat:topic:om_topic_root", "oc_group_chat"], + }); + expect( + feishuPlugin.messaging?.resolveSessionConversation?.({ + kind: "group", + rawId: "oc_group_chat:topic:om_topic_root", + }), + ).toEqual({ + id: "oc_group_chat:topic:om_topic_root", + baseConversationId: "oc_group_chat", + parentConversationCandidates: ["oc_group_chat"], + }); + expect( + feishuPlugin.messaging?.resolveSessionConversation?.({ + kind: "group", + rawId: "oc_group_chat:Topic:om_topic_root:Sender:ou_topic_user", + }), + ).toEqual({ + id: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + baseConversationId: "oc_group_chat", + parentConversationCandidates: ["oc_group_chat:topic:om_topic_root", "oc_group_chat"], + }); + }); +}); + describe("feishuPlugin actions", () => { const cfg = { channels: { diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index d50612c15ab..03106951d9d 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -55,6 +55,10 @@ import { import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { getFeishuRuntime } from "./runtime.js"; +import { + resolveFeishuParentConversationCandidates, + resolveFeishuSessionConversation, +} from "./session-conversation.js"; import { resolveFeishuOutboundSessionRoute } from "./session-route.js"; import { feishuSetupAdapter } from "./setup-core.js"; import { feishuSetupWizard } from "./setup-surface.js"; @@ -1068,6 +1072,8 @@ export const feishuPlugin: ChannelPlugin normalizeFeishuTarget(raw) ?? undefined, + resolveSessionConversation: ({ kind, rawId }) => + resolveFeishuSessionConversation({ kind, rawId }), resolveOutboundSessionRoute: (params) => resolveFeishuOutboundSessionRoute(params), targetResolver: { looksLikeId: looksLikeFeishuId, diff --git a/extensions/feishu/src/conversation-id.ts b/extensions/feishu/src/conversation-id.ts index 4f25e0adb56..eccc53657e0 100644 --- a/extensions/feishu/src/conversation-id.ts +++ b/extensions/feishu/src/conversation-id.ts @@ -100,7 +100,7 @@ export function parseFeishuConversationId(params: { return null; } - const topicSenderMatch = conversationId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/); + const topicSenderMatch = conversationId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i); if (topicSenderMatch) { const [, chatId, topicId, senderOpenId] = topicSenderMatch; return { @@ -117,7 +117,7 @@ export function parseFeishuConversationId(params: { }; } - const topicMatch = conversationId.match(/^(.+):topic:([^:]+)$/); + const topicMatch = conversationId.match(/^(.+):topic:([^:]+)$/i); if (topicMatch) { const [, chatId, topicId] = topicMatch; return { @@ -132,7 +132,7 @@ export function parseFeishuConversationId(params: { }; } - const senderMatch = conversationId.match(/^(.+):sender:([^:]+)$/); + const senderMatch = conversationId.match(/^(.+):sender:([^:]+)$/i); if (senderMatch) { const [, chatId, senderOpenId] = senderMatch; return { diff --git a/extensions/feishu/src/session-conversation.ts b/extensions/feishu/src/session-conversation.ts new file mode 100644 index 00000000000..4527038e390 --- /dev/null +++ b/extensions/feishu/src/session-conversation.ts @@ -0,0 +1,42 @@ +import { buildFeishuConversationId, parseFeishuConversationId } from "./conversation-id.js"; + +export function resolveFeishuParentConversationCandidates(rawId: string): string[] { + const parsed = parseFeishuConversationId({ conversationId: rawId }); + if (!parsed) { + return []; + } + switch (parsed.scope) { + case "group_topic_sender": + return [ + buildFeishuConversationId({ + chatId: parsed.chatId, + scope: "group_topic", + topicId: parsed.topicId, + }), + parsed.chatId, + ]; + case "group_topic": + case "group_sender": + return [parsed.chatId]; + case "group": + default: + return []; + } +} + +export function resolveFeishuSessionConversation(params: { + kind: "group" | "channel"; + rawId: string; +}) { + const parsed = parseFeishuConversationId({ conversationId: params.rawId }); + if (!parsed) { + return null; + } + return { + id: parsed.canonicalConversationId, + baseConversationId: parsed.chatId, + parentConversationCandidates: resolveFeishuParentConversationCandidates( + parsed.canonicalConversationId, + ), + }; +} diff --git a/extensions/telegram/session-key-api.ts b/extensions/telegram/session-key-api.ts new file mode 100644 index 00000000000..b37e63e294e --- /dev/null +++ b/extensions/telegram/session-key-api.ts @@ -0,0 +1 @@ +export { resolveTelegramSessionConversation as resolveSessionConversation } from "./src/session-conversation.js"; diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 1fabefdeff8..8c7f7d9309b 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -187,6 +187,39 @@ describe("telegramPlugin groups", () => { }); }); +describe("telegramPlugin messaging", () => { + it("owns topic session parsing and parent fallback candidates", () => { + expect( + telegramPlugin.messaging?.resolveSessionConversation?.({ + kind: "group", + rawId: "-1001:topic:77", + }), + ).toEqual({ + id: "-1001", + threadId: "77", + baseConversationId: "-1001", + parentConversationCandidates: ["-1001"], + }); + expect( + telegramPlugin.messaging?.resolveSessionConversation?.({ + kind: "group", + rawId: "-1001:Topic:77", + }), + ).toEqual({ + id: "-1001", + threadId: "77", + baseConversationId: "-1001", + parentConversationCandidates: ["-1001"], + }); + expect( + telegramPlugin.messaging?.resolveSessionConversation?.({ + kind: "group", + rawId: "-1001", + }), + ).toBeNull(); + }); +}); + describe("telegramPlugin duplicate token guard", () => { it("marks secondary account as not configured when token is shared", async () => { const cfg = createCfg(); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index a6897715255..8b3e648b524 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -72,6 +72,7 @@ import type { TelegramProbe } from "./probe.js"; import { resolveTelegramReactionLevel } from "./reaction-level.js"; import { getTelegramRuntime } from "./runtime.js"; import { sendMessageTelegram, sendPollTelegram, sendTypingTelegram } from "./send.js"; +import { resolveTelegramSessionConversation } from "./session-conversation.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; import { @@ -530,6 +531,8 @@ export const telegramPlugin = createChatChannelPlugin({ }, messaging: { normalizeTarget: normalizeTelegramMessagingTarget, + resolveSessionConversation: ({ kind, rawId }) => + resolveTelegramSessionConversation({ kind, rawId }), parseExplicitTarget: ({ raw }) => parseTelegramExplicitTarget(raw), inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType, formatTargetDisplay: ({ target, display, kind }) => { diff --git a/extensions/telegram/src/session-conversation.ts b/extensions/telegram/src/session-conversation.ts new file mode 100644 index 00000000000..68089c5fa81 --- /dev/null +++ b/extensions/telegram/src/session-conversation.ts @@ -0,0 +1,17 @@ +import { parseTelegramTopicConversation } from "openclaw/plugin-sdk/telegram-core"; + +export function resolveTelegramSessionConversation(params: { + kind: "group" | "channel"; + rawId: string; +}) { + const parsed = parseTelegramTopicConversation({ conversationId: params.rawId }); + if (!parsed) { + return null; + } + return { + id: parsed.chatId, + threadId: parsed.topicId, + baseConversationId: parsed.chatId, + parentConversationCandidates: [parsed.chatId], + }; +} diff --git a/src/acp/conversation-id.ts b/src/acp/conversation-id.ts index 9cf17c9a579..639d6113463 100644 --- a/src/acp/conversation-id.ts +++ b/src/acp/conversation-id.ts @@ -43,7 +43,7 @@ export function parseTelegramTopicConversation(params: { parentConversationId?: string; }): ParsedTelegramTopicConversation | null { const conversation = params.conversationId.trim(); - const directMatch = conversation.match(/^(-?\d+):topic:(\d+)$/); + const directMatch = conversation.match(/^(-?\d+):topic:(\d+)$/i); if (directMatch?.[1] && directMatch[2]) { const canonicalConversationId = buildTelegramTopicConversationId({ chatId: directMatch[1], diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index ad7bbaadaca..df01c14fcdc 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -1,9 +1,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import "./test-helpers/fast-coding-tools.js"; import type { OpenClawConfig } from "../config/config.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js"; import { createOpenClawCodingTools } from "./pi-tools.js"; import type { SandboxDockerConfig } from "./sandbox.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; @@ -14,6 +16,10 @@ type ToolWithExecute = { }; describe("Agent-specific tool filtering", () => { + beforeEach(() => { + setActivePluginRegistry(createSessionConversationTestRegistry()); + }); + const sandboxFsBridgeStub: SandboxFsBridge = { resolvePath: () => ({ hostPath: "/tmp/sandbox", @@ -515,6 +521,31 @@ describe("Agent-specific tool filtering", () => { expect(names).not.toContain("exec"); }); + it("should resolve feishu group tool policy for sender-scoped session keys", () => { + const cfg: OpenClawConfig = { + channels: { + feishu: { + groups: { + oc_group_chat: { + tools: { allow: ["read"] }, + }, + }, + }, + }, + }; + + const tools = createOpenClawCodingTools({ + config: cfg, + sessionKey: "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + messageProvider: "feishu", + workspaceDir: "/tmp/test-feishu-scoped-group", + agentDir: "/tmp/agent-feishu", + }); + const names = tools.map((t) => t.name); + expect(names).toContain("read"); + expect(names).not.toContain("exec"); + }); + it("should inherit group tool policy for subagents from spawnedBy session keys", () => { const cfg: OpenClawConfig = { channels: { diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index cc5770015ee..cc6d2ef594f 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -1,10 +1,13 @@ import { getChannelPlugin } from "../channels/plugins/index.js"; +import { + resolveSessionConversationRef, + resolveSessionParentSessionKey, +} from "../channels/plugins/session-conversation.js"; import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js"; import type { AgentToolsConfig } from "../config/types.tools.js"; import { normalizeAgentId } from "../routing/session-key.js"; -import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; @@ -136,7 +139,18 @@ function resolveGroupContextFromSessionKey(sessionKey?: string | null): { if (!raw) { return {}; } - const base = resolveThreadParentSessionKey(raw) ?? raw; + const resolvedConversation = resolveSessionConversationRef(raw); + if (resolvedConversation) { + const groupId = resolvedConversation.baseConversationId; + if (!groupId) { + return {}; + } + return { + channel: resolvedConversation.channel, + groupId, + }; + } + const base = resolveSessionParentSessionKey(raw) ?? raw; const parts = base.split(":").filter(Boolean); let body = parts[0] === "agent" ? parts.slice(2) : parts; if (body[0] === "subagent") { diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 7166e3968d1..973b81ea7ac 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -167,8 +167,8 @@ export function createGatewayTool(opts?: { : undefined; const note = typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined; - // Extract channel + threadId for routing after restart - // Supports both :thread: (most channels) and :topic: (Telegram) + // Extract channel + threadId for routing after restart. + // Uses generic :thread: parsing plus plugin-owned session grammars. const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey); const payload: RestartSentinelPayload = { kind: "restart", diff --git a/src/agents/tools/sessions-send-helpers.test.ts b/src/agents/tools/sessions-send-helpers.test.ts index 3dfa3f9ac2d..7e3eedfb9a7 100644 --- a/src/agents/tools/sessions-send-helpers.test.ts +++ b/src/agents/tools/sessions-send-helpers.test.ts @@ -1,124 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; import { resolveAnnounceTargetFromKey } from "./sessions-send-helpers.js"; describe("resolveAnnounceTargetFromKey", () => { beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "discord", - source: "test", - plugin: { - id: "discord", - meta: { - id: "discord", - label: "Discord", - selectionLabel: "Discord", - docsPath: "/channels/discord", - blurb: "Discord test stub.", - }, - capabilities: { chatTypes: ["direct", "channel", "thread"] }, - messaging: { - resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, - }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - { - pluginId: "slack", - source: "test", - plugin: { - id: "slack", - meta: { - id: "slack", - label: "Slack", - selectionLabel: "Slack", - docsPath: "/channels/slack", - blurb: "Slack test stub.", - }, - capabilities: { chatTypes: ["direct", "channel", "thread"] }, - messaging: { - resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, - }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - { - pluginId: "matrix", - source: "test", - plugin: { - id: "matrix", - meta: { - id: "matrix", - label: "Matrix", - selectionLabel: "Matrix", - docsPath: "/channels/matrix", - blurb: "Matrix test stub.", - }, - capabilities: { chatTypes: ["direct", "channel", "thread"] }, - messaging: { - resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, - }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - { - pluginId: "telegram", - source: "test", - plugin: { - id: "telegram", - meta: { - id: "telegram", - label: "Telegram", - selectionLabel: "Telegram", - docsPath: "/channels/telegram", - blurb: "Telegram test stub.", - }, - capabilities: { chatTypes: ["direct", "group", "thread"] }, - messaging: { - normalizeTarget: (raw: string) => raw.replace(/^group:/, ""), - }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - { - pluginId: "feishu", - source: "test", - plugin: { - id: "feishu", - meta: { - id: "feishu", - label: "Feishu", - selectionLabel: "Feishu", - docsPath: "/channels/feishu", - blurb: "Feishu test stub.", - }, - capabilities: { chatTypes: ["direct", "group", "thread"] }, - messaging: { - normalizeTarget: (raw: string) => raw.replace(/^group:/, ""), - }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - ]), - ); + setActivePluginRegistry(createSessionConversationTestRegistry()); }); it("lets plugins own session-derived target shapes", () => { diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts index c9d4e8fd6eb..66d8129ea35 100644 --- a/src/agents/tools/sessions-send-helpers.ts +++ b/src/agents/tools/sessions-send-helpers.ts @@ -2,9 +2,9 @@ import { getChannelPlugin, normalizeChannelId as normalizeAnyChannelId, } from "../../channels/plugins/index.js"; +import { resolveSessionConversationRef } from "../../channels/plugins/session-conversation.js"; import { normalizeChannelId as normalizeChatChannelId } from "../../channels/registry.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { parseSessionConversationRef } from "../../sessions/session-key-utils.js"; const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP"; const REPLY_SKIP_TOKEN = "REPLY_SKIP"; @@ -19,7 +19,7 @@ export type AnnounceTarget = { }; export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget | null { - const parsed = parseSessionConversationRef(sessionKey); + const parsed = resolveSessionConversationRef(sessionKey); if (!parsed) { return null; } diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 8f911af8cf3..adf1d9d9d5c 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -267,6 +267,7 @@ export async function getReplyFromConfig( : undefined) ?? finalized.Provider, groupId: groupResolution?.id ?? sessionEntry.groupId, + groupChatType: sessionEntry.chatType ?? sessionCtx.ChatType ?? finalized.ChatType, groupChannel: sessionEntry.groupChannel ?? sessionCtx.GroupChannel ?? finalized.GroupChannel, groupSubject: sessionEntry.subject ?? sessionCtx.GroupSubject ?? finalized.GroupSubject, parentSessionKey: sessionCtx.ParentSessionKey, diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index ea474c6d6a8..21e3e54ee63 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -1,8 +1,10 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MODEL_CONTEXT_TOKEN_CACHE } from "../../agents/context-cache.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; import { createModelSelectionState, resolveContextTokens } from "./model-selection.js"; vi.mock("../../agents/model-catalog.js", () => ({ @@ -21,6 +23,10 @@ afterEach(() => { MODEL_CONTEXT_TOKEN_CACHE.clear(); }); +beforeEach(() => { + setActivePluginRegistry(createSessionConversationTestRegistry()); +}); + const makeConfiguredModel = (overrides: Record = {}) => ({ id: "gpt-5.4", name: "GPT-5.4", diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index cc12cb935de..7765d925d3d 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -14,10 +14,10 @@ import { resolveReasoningDefault, resolveThinkingDefault, } from "../../agents/model-selection.js"; +import { resolveSessionParentSessionKey } from "../../channels/plugins/session-conversation.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; -import { resolveThreadParentSessionKey } from "../../sessions/session-key-utils.js"; import type { ThinkLevel } from "./directives.js"; export type ModelDirectiveSelection = { @@ -146,7 +146,7 @@ function resolveParentSessionKeyCandidate(params: { if (explicit && explicit !== params.sessionKey) { return explicit; } - const derived = resolveThreadParentSessionKey(params.sessionKey); + const derived = resolveSessionParentSessionKey(params.sessionKey); if (derived && derived !== params.sessionKey) { return derived; } diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index b0774239b65..256efc6ebe2 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -763,6 +763,7 @@ export function buildStatusMessage(args: StatusArgs): string { cfg: args.config, channel: entry.channel ?? entry.origin?.provider, groupId: entry.groupId, + groupChatType: entry.chatType ?? entry.origin?.chatType, groupChannel: entry.groupChannel, groupSubject: entry.subject, parentSessionKey: args.parentSessionKey, diff --git a/src/channels/model-overrides.test.ts b/src/channels/model-overrides.test.ts index 6e4d64d0dd4..ef00f76a7c0 100644 --- a/src/channels/model-overrides.test.ts +++ b/src/channels/model-overrides.test.ts @@ -1,8 +1,15 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js"; import { resolveChannelModelOverride } from "./model-overrides.js"; describe("resolveChannelModelOverride", () => { + beforeEach(() => { + setActivePluginRegistry(createSessionConversationTestRegistry()); + }); + it.each([ { name: "matches parent group id when topic suffix is present", @@ -104,4 +111,125 @@ describe("resolveChannelModelOverride", () => { expect(resolved?.model).toBe(expected.model); expect(resolved?.matchKey).toBe(expected.matchKey); }); + + it("passes channel kind to plugin-owned parent fallback resolution", () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "channel-kind", + source: "test", + plugin: { + id: "channel-kind", + meta: { + id: "channel-kind", + label: "Channel Kind", + selectionLabel: "Channel Kind", + docsPath: "/channels/channel-kind", + blurb: "test stub.", + }, + capabilities: { chatTypes: ["group", "channel"] }, + messaging: { + resolveSessionConversation: ({ + kind, + rawId, + }: { + kind: "group" | "channel"; + rawId: string; + }) => ({ + id: rawId, + parentConversationCandidates: kind === "channel" ? ["thread-parent"] : [], + }), + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + ]), + ); + + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + "channel-kind": { + "thread-parent": "demo-provider/demo-channel-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "channel-kind", + groupId: "thread-123", + groupChatType: "channel", + }); + + expect(resolved?.model).toBe("demo-provider/demo-channel-model"); + expect(resolved?.matchKey).toBe("thread-parent"); + }); + + it("keeps bundled Feishu parent fallback matching before registry bootstrap", () => { + resetPluginRuntimeStateForTest(); + + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + feishu: { + "oc_group_chat:topic:om_topic_root": "demo-provider/demo-feishu-topic-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "feishu", + groupId: "unrelated", + parentSessionKey: + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + }); + + expect(resolved?.model).toBe("demo-provider/demo-feishu-topic-model"); + expect(resolved?.matchKey).toBe("oc_group_chat:topic:om_topic_root"); + }); + + it("keeps mixed-case Feishu scoped markers when matching parent session fallbacks", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + feishu: { + "oc_group_chat:topic:om_topic_root": "demo-provider/demo-feishu-topic-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "feishu", + groupId: "unrelated", + parentSessionKey: + "agent:main:feishu:group:oc_group_chat:Topic:om_topic_root:Sender:ou_topic_user", + }); + + expect(resolved?.model).toBe("demo-provider/demo-feishu-topic-model"); + expect(resolved?.matchKey).toBe("oc_group_chat:topic:om_topic_root"); + }); + + it("prefers parent conversation ids over channel-name fallbacks", () => { + const resolved = resolveChannelModelOverride({ + cfg: { + channels: { + modelByChannel: { + telegram: { + "-100123": "demo-provider/demo-parent-model", + "#general": "demo-provider/demo-channel-name-model", + }, + }, + }, + } as unknown as OpenClawConfig, + channel: "telegram", + groupId: "-100123:topic:99", + groupChannel: "#general", + }); + + expect(resolved?.model).toBe("demo-provider/demo-parent-model"); + expect(resolved?.matchKey).toBe("-100123"); + }); }); diff --git a/src/channels/model-overrides.ts b/src/channels/model-overrides.ts index 74093073253..a5217e8c4a9 100644 --- a/src/channels/model-overrides.ts +++ b/src/channels/model-overrides.ts @@ -1,8 +1,4 @@ import type { OpenClawConfig } from "../config/config.js"; -import { - parseSessionConversationRef, - parseThreadSessionSuffix, -} from "../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { buildChannelKeyCandidates, @@ -10,6 +6,11 @@ import { resolveChannelEntryMatchWithFallback, type ChannelMatchSource, } from "./channel-config.js"; +import { normalizeChatType } from "./chat-type.js"; +import { + resolveSessionConversation, + resolveSessionConversationRef, +} from "./plugins/session-conversation.js"; export type ChannelModelOverride = { channel: string; @@ -24,6 +25,7 @@ type ChannelModelOverrideParams = { cfg: OpenClawConfig; channel?: string | null; groupId?: string | null; + groupChatType?: string | null; groupChannel?: string | null; groupSubject?: string | null; parentSessionKey?: string | null; @@ -45,51 +47,27 @@ function resolveProviderEntry( ); } -function resolveParentGroupId( - groupId: string | undefined, - channelHint?: string | null, -): string | undefined { - const raw = groupId?.trim(); - if (!raw) { - return undefined; - } - const parent = parseThreadSessionSuffix(raw, { channelHint }).baseSessionKey?.trim(); - return parent && parent !== raw ? parent : undefined; -} - -function resolveSenderScopedParentGroupId(groupId: string | undefined): string | undefined { - const raw = groupId?.trim(); - if (!raw) { - return undefined; - } - const parent = raw.replace(/:sender:[^:]+$/i, "").trim(); - return parent && parent !== raw ? parent : undefined; -} - -function resolveGroupIdFromSessionKey(sessionKey?: string | null): string | undefined { - return parseSessionConversationRef(sessionKey)?.id; -} - function buildChannelCandidates( params: Pick< ChannelModelOverrideParams, - "channel" | "groupId" | "groupChannel" | "groupSubject" | "parentSessionKey" + "channel" | "groupId" | "groupChatType" | "groupChannel" | "groupSubject" | "parentSessionKey" >, -) { +): { keys: string[]; parentKeys: string[] } { const normalizedChannel = normalizeMessageChannel(params.channel ?? "") ?? params.channel?.trim().toLowerCase(); const groupId = params.groupId?.trim(); - const senderParentGroupId = resolveSenderScopedParentGroupId(groupId); - const parentGroupId = resolveParentGroupId(groupId, normalizedChannel); - const parentGroupIdFromSession = resolveGroupIdFromSessionKey(params.parentSessionKey); - const senderParentGroupIdFromSession = resolveSenderScopedParentGroupId(parentGroupIdFromSession); - const parentGroupIdResolved = - resolveParentGroupId(parentGroupIdFromSession, normalizedChannel) ?? parentGroupIdFromSession; - const senderParentResolved = - resolveParentGroupId(senderParentGroupId, normalizedChannel) ?? senderParentGroupId; - const senderParentFromSessionResolved = - resolveParentGroupId(senderParentGroupIdFromSession, normalizedChannel) ?? - senderParentGroupIdFromSession; + const sessionConversation = resolveSessionConversationRef(params.parentSessionKey); + const groupConversationKind = + normalizeChatType(params.groupChatType ?? undefined) === "channel" + ? "channel" + : sessionConversation?.kind === "channel" + ? "channel" + : "group"; + const groupConversation = resolveSessionConversation({ + channel: normalizedChannel ?? "", + kind: groupConversationKind, + rawId: groupId ?? "", + }); const groupChannel = params.groupChannel?.trim(); const groupSubject = params.groupSubject?.trim(); const channelBare = groupChannel ? groupChannel.replace(/^#/, "") : undefined; @@ -97,22 +75,22 @@ function buildChannelCandidates( const channelSlug = channelBare ? normalizeChannelSlug(channelBare) : undefined; const subjectSlug = subjectBare ? normalizeChannelSlug(subjectBare) : undefined; - return buildChannelKeyCandidates( - groupId, - senderParentGroupId, - senderParentResolved, - parentGroupId, - parentGroupIdFromSession, - senderParentGroupIdFromSession, - senderParentFromSessionResolved, - parentGroupIdResolved, - groupChannel, - channelBare, - channelSlug, - groupSubject, - subjectBare, - subjectSlug, - ); + return { + keys: buildChannelKeyCandidates( + groupId, + sessionConversation?.rawId, + ...(groupConversation?.parentConversationCandidates ?? []), + ...(sessionConversation?.parentConversationCandidates ?? []), + ), + parentKeys: buildChannelKeyCandidates( + groupChannel, + channelBare, + channelSlug, + groupSubject, + subjectBare, + subjectSlug, + ), + }; } export function resolveChannelModelOverride( @@ -133,13 +111,14 @@ export function resolveChannelModelOverride( return null; } - const candidates = buildChannelCandidates(params); - if (candidates.length === 0) { + const { keys, parentKeys } = buildChannelCandidates(params); + if (keys.length === 0 && parentKeys.length === 0) { return null; } const match = resolveChannelEntryMatchWithFallback({ entries: providerEntries, - keys: candidates, + keys, + parentKeys, wildcardKey: "*", normalizeKey: (value) => value.trim().toLowerCase(), }); diff --git a/src/channels/plugins/session-conversation.test.ts b/src/channels/plugins/session-conversation.test.ts new file mode 100644 index 00000000000..6693b36a4e1 --- /dev/null +++ b/src/channels/plugins/session-conversation.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; +import { + resolveSessionConversation, + resolveSessionConversationRef, + resolveSessionParentSessionKey, + resolveSessionThreadInfo, +} from "./session-conversation.js"; + +describe("session conversation routing", () => { + beforeEach(() => { + setActivePluginRegistry(createSessionConversationTestRegistry()); + }); + + it("keeps generic :thread: parsing in core", () => { + expect( + resolveSessionConversationRef("agent:main:slack:channel:general:thread:1699999999.0001"), + ).toEqual({ + channel: "slack", + kind: "channel", + rawId: "general:thread:1699999999.0001", + id: "general", + threadId: "1699999999.0001", + baseSessionKey: "agent:main:slack:channel:general", + baseConversationId: "general", + parentConversationCandidates: ["general"], + }); + }); + + it("lets Telegram own :topic: session grammar", () => { + expect(resolveSessionConversationRef("agent:main:telegram:group:-100123:topic:77")).toEqual({ + channel: "telegram", + kind: "group", + rawId: "-100123:topic:77", + id: "-100123", + threadId: "77", + baseSessionKey: "agent:main:telegram:group:-100123", + baseConversationId: "-100123", + parentConversationCandidates: ["-100123"], + }); + expect(resolveSessionThreadInfo("agent:main:telegram:group:-100123:topic:77")).toEqual({ + baseSessionKey: "agent:main:telegram:group:-100123", + threadId: "77", + }); + expect(resolveSessionParentSessionKey("agent:main:telegram:group:-100123:topic:77")).toBe( + "agent:main:telegram:group:-100123", + ); + }); + + it("keeps bundled Telegram topic parsing available before registry bootstrap", () => { + resetPluginRuntimeStateForTest(); + + expect(resolveSessionConversationRef("agent:main:telegram:group:-100123:topic:77")).toEqual({ + channel: "telegram", + kind: "group", + rawId: "-100123:topic:77", + id: "-100123", + threadId: "77", + baseSessionKey: "agent:main:telegram:group:-100123", + baseConversationId: "-100123", + parentConversationCandidates: ["-100123"], + }); + }); + + it("keeps bundled Feishu parent fallbacks available before registry bootstrap", () => { + resetPluginRuntimeStateForTest(); + + expect( + resolveSessionConversationRef( + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + ), + ).toEqual({ + channel: "feishu", + kind: "group", + rawId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + id: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + threadId: undefined, + baseSessionKey: + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + baseConversationId: "oc_group_chat", + parentConversationCandidates: ["oc_group_chat:topic:om_topic_root", "oc_group_chat"], + }); + }); + + it("lets Feishu own parent fallback candidates", () => { + expect( + resolveSessionConversationRef( + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + ), + ).toEqual({ + channel: "feishu", + kind: "group", + rawId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + id: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + threadId: undefined, + baseSessionKey: + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + baseConversationId: "oc_group_chat", + parentConversationCandidates: ["oc_group_chat:topic:om_topic_root", "oc_group_chat"], + }); + expect( + resolveSessionParentSessionKey( + "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user", + ), + ).toBeNull(); + }); + + it("keeps the legacy parent-candidate hook as a fallback only", () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "legacy-parent", + source: "test", + plugin: { + id: "legacy-parent", + meta: { + id: "legacy-parent", + label: "Legacy Parent", + selectionLabel: "Legacy Parent", + docsPath: "/channels/legacy-parent", + blurb: "test stub.", + }, + capabilities: { chatTypes: ["group"] }, + messaging: { + resolveParentConversationCandidates: ({ rawId }: { rawId: string }) => + rawId.endsWith(":sender:user") ? [rawId.replace(/:sender:user$/i, "")] : null, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + ]), + ); + + expect( + resolveSessionConversation({ + channel: "legacy-parent", + kind: "group", + rawId: "room:sender:user", + }), + ).toEqual({ + id: "room:sender:user", + threadId: undefined, + baseConversationId: "room", + parentConversationCandidates: ["room"], + }); + }); +}); diff --git a/src/channels/plugins/session-conversation.ts b/src/channels/plugins/session-conversation.ts new file mode 100644 index 00000000000..d5034ba5bb8 --- /dev/null +++ b/src/channels/plugins/session-conversation.ts @@ -0,0 +1,277 @@ +import { fileURLToPath } from "node:url"; +import { loadBundledPluginPublicSurfaceModuleSync } from "../../plugin-sdk/facade-runtime.js"; +import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js"; +import { resolveBundledPluginPublicSurfacePath } from "../../plugins/bundled-plugin-metadata.js"; +import { + parseRawSessionConversationRef, + parseThreadSessionSuffix, + type ParsedThreadSessionSuffix, + type RawSessionConversationRef, +} from "../../sessions/session-key-utils.js"; +import { normalizeChannelId as normalizeChatChannelId } from "../registry.js"; +import { getChannelPlugin, normalizeChannelId as normalizeAnyChannelId } from "./registry.js"; + +export type ResolvedSessionConversation = { + id: string; + threadId: string | undefined; + baseConversationId: string; + parentConversationCandidates: string[]; +}; + +export type ResolvedSessionConversationRef = { + channel: string; + kind: "group" | "channel"; + rawId: string; + id: string; + threadId: string | undefined; + baseSessionKey: string; + baseConversationId: string; + parentConversationCandidates: string[]; +}; + +type SessionConversationHookResult = { + id: string; + threadId?: string | null; + baseConversationId?: string | null; + parentConversationCandidates?: string[]; +}; + +type SessionConversationResolverParams = { + kind: "group" | "channel"; + rawId: string; +}; + +type BundledSessionKeyModule = { + resolveSessionConversation?: ( + params: SessionConversationResolverParams, + ) => SessionConversationHookResult | null; +}; + +const OPENCLAW_PACKAGE_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); +const SESSION_KEY_API_ARTIFACT_BASENAME = "session-key-api.js"; + +type NormalizedSessionConversationResolution = ResolvedSessionConversation & { + hasExplicitParentConversationCandidates: boolean; +}; + +function normalizeResolvedChannel(channel: string): string { + return ( + normalizeAnyChannelId(channel) ?? + normalizeChatChannelId(channel) ?? + channel.trim().toLowerCase() + ); +} + +function getMessagingAdapter(channel: string) { + const normalizedChannel = normalizeResolvedChannel(channel); + try { + return getChannelPlugin(normalizedChannel)?.messaging; + } catch { + return undefined; + } +} + +function dedupeConversationIds(values: Array): string[] { + const seen = new Set(); + const resolved: string[] = []; + for (const value of values) { + if (typeof value !== "string") { + continue; + } + const trimmed = value.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + resolved.push(trimmed); + } + return resolved; +} + +function buildGenericConversationResolution(rawId: string): ResolvedSessionConversation | null { + const trimmed = rawId.trim(); + if (!trimmed) { + return null; + } + + const parsed = parseThreadSessionSuffix(trimmed); + const id = (parsed.baseSessionKey ?? trimmed).trim(); + if (!id) { + return null; + } + + return { + id, + threadId: parsed.threadId, + baseConversationId: id, + parentConversationCandidates: dedupeConversationIds( + parsed.threadId ? [parsed.baseSessionKey] : [], + ), + }; +} + +function normalizeSessionConversationResolution( + resolved: SessionConversationHookResult | null | undefined, +): NormalizedSessionConversationResolution | null { + if (!resolved?.id?.trim()) { + return null; + } + + return { + id: resolved.id.trim(), + threadId: resolved.threadId?.trim() || undefined, + baseConversationId: + resolved.baseConversationId?.trim() || + dedupeConversationIds(resolved.parentConversationCandidates ?? []).at(-1) || + resolved.id.trim(), + parentConversationCandidates: dedupeConversationIds( + resolved.parentConversationCandidates ?? [], + ), + hasExplicitParentConversationCandidates: Object.hasOwn( + resolved, + "parentConversationCandidates", + ), + }; +} + +function resolveBundledSessionConversationFallback(params: { + channel: string; + kind: "group" | "channel"; + rawId: string; +}): NormalizedSessionConversationResolution | null { + const dirName = normalizeResolvedChannel(params.channel); + if ( + !resolveBundledPluginPublicSurfacePath({ + rootDir: OPENCLAW_PACKAGE_ROOT, + bundledPluginsDir: resolveBundledPluginsDir(), + dirName, + artifactBasename: SESSION_KEY_API_ARTIFACT_BASENAME, + }) + ) { + return null; + } + + const resolveSessionConversation = + loadBundledPluginPublicSurfaceModuleSync({ + dirName, + artifactBasename: SESSION_KEY_API_ARTIFACT_BASENAME, + }).resolveSessionConversation; + if (typeof resolveSessionConversation !== "function") { + return null; + } + + return normalizeSessionConversationResolution( + resolveSessionConversation({ + kind: params.kind, + rawId: params.rawId, + }), + ); +} + +function resolveSessionConversationResolution(params: { + channel: string; + kind: "group" | "channel"; + rawId: string; +}): ResolvedSessionConversation | null { + const rawId = params.rawId.trim(); + if (!rawId) { + return null; + } + + const messaging = getMessagingAdapter(params.channel); + const pluginResolved = normalizeSessionConversationResolution( + messaging?.resolveSessionConversation?.({ + kind: params.kind, + rawId, + }), + ); + const resolved = + pluginResolved ?? + resolveBundledSessionConversationFallback({ + channel: params.channel, + kind: params.kind, + rawId, + }) ?? + buildGenericConversationResolution(rawId); + if (!resolved) { + return null; + } + + const parentConversationCandidates = dedupeConversationIds( + pluginResolved?.hasExplicitParentConversationCandidates + ? resolved.parentConversationCandidates + : (messaging?.resolveParentConversationCandidates?.({ + kind: params.kind, + rawId, + }) ?? resolved.parentConversationCandidates), + ); + const baseConversationId = + parentConversationCandidates.at(-1) ?? resolved.baseConversationId ?? resolved.id; + + return { + ...resolved, + baseConversationId, + parentConversationCandidates, + }; +} + +export function resolveSessionConversation(params: { + channel: string; + kind: "group" | "channel"; + rawId: string; +}): ResolvedSessionConversation | null { + return resolveSessionConversationResolution(params); +} + +function buildBaseSessionKey(raw: RawSessionConversationRef, id: string): string { + return `${raw.prefix}:${id}`; +} + +export function resolveSessionConversationRef( + sessionKey: string | undefined | null, +): ResolvedSessionConversationRef | null { + const raw = parseRawSessionConversationRef(sessionKey); + if (!raw) { + return null; + } + + const resolved = resolveSessionConversation(raw); + if (!resolved) { + return null; + } + + return { + channel: normalizeResolvedChannel(raw.channel), + kind: raw.kind, + rawId: raw.rawId, + id: resolved.id, + threadId: resolved.threadId, + baseSessionKey: buildBaseSessionKey(raw, resolved.id), + baseConversationId: resolved.baseConversationId, + parentConversationCandidates: resolved.parentConversationCandidates, + }; +} + +export function resolveSessionThreadInfo( + sessionKey: string | undefined | null, +): ParsedThreadSessionSuffix { + const resolved = resolveSessionConversationRef(sessionKey); + if (!resolved) { + return parseThreadSessionSuffix(sessionKey); + } + + return { + baseSessionKey: resolved.threadId ? resolved.baseSessionKey : sessionKey?.trim() || undefined, + threadId: resolved.threadId, + }; +} + +export function resolveSessionParentSessionKey( + sessionKey: string | undefined | null, +): string | null { + const { baseSessionKey, threadId } = resolveSessionThreadInfo(sessionKey); + if (!threadId) { + return null; + } + return baseSessionKey ?? null; +} diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 704ebca0387..f74d438fe96 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -397,6 +397,33 @@ export type ChannelThreadingToolContext = { /** Channel-owned messaging helpers for target parsing, routing, and payload shaping. */ export type ChannelMessagingAdapter = { normalizeTarget?: (raw: string) => string | undefined; + /** + * Canonical plugin-owned session conversation grammar. + * Use this when the provider encodes thread or scoped-conversation semantics + * inside `rawId` (for example Telegram topics or Feishu sender scopes). + * Return `baseConversationId` and `parentConversationCandidates` here when + * you can so parsing and inheritance stay in one place. + * `parentConversationCandidates`, when present, should be ordered from the + * narrowest parent to the broadest/base conversation. + * Bundled plugins that need the same grammar before runtime bootstrap can + * mirror this contract through a top-level `session-key-api.ts` surface. + */ + resolveSessionConversation?: (params: { kind: "group" | "channel"; rawId: string }) => { + id: string; + threadId?: string | null; + baseConversationId?: string | null; + parentConversationCandidates?: string[]; + } | null; + /** + * Legacy compatibility hook for parent fallbacks when a plugin does not need + * to customize `id` or `threadId`. Core only uses this when + * `resolveSessionConversation(...)` does not return + * `parentConversationCandidates`. + */ + resolveParentConversationCandidates?: (params: { + kind: "group" | "channel"; + rawId: string; + }) => string[] | null; resolveSessionTarget?: (params: { kind: "group" | "channel"; id: string; diff --git a/src/config/sessions/delivery-info.test.ts b/src/config/sessions/delivery-info.test.ts index 9f7c8437818..002b40f6479 100644 --- a/src/config/sessions/delivery-info.test.ts +++ b/src/config/sessions/delivery-info.test.ts @@ -1,4 +1,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; import type { SessionEntry } from "./types.js"; const storeState = vi.hoisted(() => ({ @@ -31,6 +33,7 @@ beforeAll(async () => { }); beforeEach(() => { + setActivePluginRegistry(createSessionConversationTestRegistry()); storeState.store = {}; }); diff --git a/src/config/sessions/delivery-info.ts b/src/config/sessions/delivery-info.ts index e380c969e4e..91295b1814b 100644 --- a/src/config/sessions/delivery-info.ts +++ b/src/config/sessions/delivery-info.ts @@ -1,17 +1,17 @@ -import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js"; +import { resolveSessionThreadInfo } from "../../channels/plugins/session-conversation.js"; import { loadConfig } from "../io.js"; import { resolveStorePath } from "./paths.js"; import { loadSessionStore } from "./store.js"; /** * Extract deliveryContext and threadId from a sessionKey. - * Supports both :thread: (most channels) and :topic: (Telegram). + * Supports generic :thread: suffixes plus plugin-owned thread/session grammars. */ export function parseSessionThreadInfo(sessionKey: string | undefined): { baseSessionKey: string | undefined; threadId: string | undefined; } { - return parseThreadSessionSuffix(sessionKey); + return resolveSessionThreadInfo(sessionKey); } export function extractDeliveryInfo(sessionKey: string | undefined): { diff --git a/src/config/sessions/reset.test.ts b/src/config/sessions/reset.test.ts index 91bdb3ebcb6..3c6b6aabb7c 100644 --- a/src/config/sessions/reset.test.ts +++ b/src/config/sessions/reset.test.ts @@ -1,7 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; import { isThreadSessionKey, resolveSessionResetType } from "./reset.js"; describe("session reset thread detection", () => { + beforeEach(() => { + setActivePluginRegistry(createSessionConversationTestRegistry()); + }); + it("does not treat feishu conversation ids with embedded :topic: as thread suffixes", () => { const sessionKey = "agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user"; diff --git a/src/config/sessions/reset.ts b/src/config/sessions/reset.ts index 00681ffcd79..8f53a1b3f8a 100644 --- a/src/config/sessions/reset.ts +++ b/src/config/sessions/reset.ts @@ -1,4 +1,4 @@ -import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js"; +import { resolveSessionThreadInfo } from "../../channels/plugins/session-conversation.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import type { SessionConfig, SessionResetConfig } from "../types.base.js"; import { DEFAULT_IDLE_MINUTES } from "./types.js"; @@ -24,7 +24,7 @@ export const DEFAULT_RESET_AT_HOUR = 4; const GROUP_SESSION_MARKERS = [":group:", ":channel:"]; export function isThreadSessionKey(sessionKey?: string | null): boolean { - return Boolean(parseThreadSessionSuffix(sessionKey).threadId); + return Boolean(resolveSessionThreadInfo(sessionKey).threadId); } export function resolveSessionResetType(params: { diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 83414b9a5a3..b561f1f22b7 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -267,8 +267,8 @@ function resolveConfigRestartRequest(params: unknown): { } { const { sessionKey, note, restartDelayMs } = parseRestartRequestParams(params); - // Extract deliveryContext + threadId for routing after restart - // Supports both :thread: (most channels) and :topic: (Telegram) + // Extract deliveryContext + threadId for routing after restart. + // Uses generic :thread: parsing plus plugin-owned session grammars. const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey); return { diff --git a/src/routing/session-key.test.ts b/src/routing/session-key.test.ts index fb58dc3bd22..b8f0724f02d 100644 --- a/src/routing/session-key.test.ts +++ b/src/routing/session-key.test.ts @@ -104,27 +104,21 @@ describe("thread session suffix parsing", () => { ).toBeNull(); }); - it("still parses telegram topic session suffixes", () => { + it("does not treat telegram :topic: as a generic thread suffix", () => { expect(parseThreadSessionSuffix("agent:main:telegram:group:-100123:topic:77")).toEqual({ - baseSessionKey: "agent:main:telegram:group:-100123", - threadId: "77", + baseSessionKey: "agent:main:telegram:group:-100123:topic:77", + threadId: undefined, }); - expect(resolveThreadParentSessionKey("agent:main:telegram:group:-100123:topic:77")).toBe( - "agent:main:telegram:group:-100123", - ); + expect(resolveThreadParentSessionKey("agent:main:telegram:group:-100123:topic:77")).toBeNull(); }); - it("parses mixed-case suffix markers without lowercasing the stored key", () => { + it("parses mixed-case :thread: markers without lowercasing the stored key", () => { expect( parseThreadSessionSuffix("agent:main:slack:channel:General:Thread:1699999999.0001"), ).toEqual({ baseSessionKey: "agent:main:slack:channel:General", threadId: "1699999999.0001", }); - expect(parseThreadSessionSuffix("agent:main:telegram:group:-100123:Topic:77")).toEqual({ - baseSessionKey: "agent:main:telegram:group:-100123", - threadId: "77", - }); }); }); diff --git a/src/sessions/session-key-utils.ts b/src/sessions/session-key-utils.ts index 30e1607eb7a..940c22fb957 100644 --- a/src/sessions/session-key-utils.ts +++ b/src/sessions/session-key-utils.ts @@ -9,11 +9,11 @@ export type ParsedThreadSessionSuffix = { threadId: string | undefined; }; -export type ParsedSessionConversationRef = { +export type RawSessionConversationRef = { channel: string; kind: "group" | "channel"; - id: string; - threadId: string | undefined; + rawId: string; + prefix: string; }; /** @@ -118,40 +118,24 @@ export function isAcpSessionKey(sessionKey: string | undefined | null): boolean return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:")); } -function normalizeThreadSuffixChannelHint(value: string | undefined | null): string | undefined { +function normalizeSessionConversationChannel(value: string | undefined | null): string | undefined { const trimmed = (value ?? "").trim().toLowerCase(); return trimmed || undefined; } -function inferThreadSuffixChannelHint(sessionKey: string): string | undefined { - const parts = sessionKey.split(":").filter(Boolean); - if (parts.length === 0) { - return undefined; - } - if ((parts[0] ?? "").trim().toLowerCase() === "agent") { - return normalizeThreadSuffixChannelHint(parts[2]); - } - return normalizeThreadSuffixChannelHint(parts[0]); -} - export function parseThreadSessionSuffix( sessionKey: string | undefined | null, - options?: { channelHint?: string | null }, ): ParsedThreadSessionSuffix { const raw = (sessionKey ?? "").trim(); if (!raw) { return { baseSessionKey: undefined, threadId: undefined }; } - const channelHint = - normalizeThreadSuffixChannelHint(options?.channelHint) ?? inferThreadSuffixChannelHint(raw); const lowerRaw = raw.toLowerCase(); - const topicMarker = ":topic:"; const threadMarker = ":thread:"; - const topicIndex = channelHint === "telegram" ? lowerRaw.lastIndexOf(topicMarker) : -1; const threadIndex = lowerRaw.lastIndexOf(threadMarker); - const markerIndex = Math.max(topicIndex, threadIndex); - const marker = topicIndex > threadIndex ? topicMarker : threadMarker; + const markerIndex = threadIndex; + const marker = threadMarker; const baseSessionKey = markerIndex === -1 ? raw : raw.slice(0, markerIndex); const threadIdRaw = markerIndex === -1 ? undefined : raw.slice(markerIndex + marker.length); @@ -160,39 +144,38 @@ export function parseThreadSessionSuffix( return { baseSessionKey, threadId }; } -export function parseSessionConversationRef( +export function parseRawSessionConversationRef( sessionKey: string | undefined | null, -): ParsedSessionConversationRef | null { +): RawSessionConversationRef | null { const raw = (sessionKey ?? "").trim(); if (!raw) { return null; } const rawParts = raw.split(":").filter(Boolean); - const parts = - rawParts.length >= 3 && rawParts[0]?.trim().toLowerCase() === "agent" - ? rawParts.slice(2) - : rawParts; + const bodyStartIndex = + rawParts.length >= 3 && rawParts[0]?.trim().toLowerCase() === "agent" ? 2 : 0; + const parts = rawParts.slice(bodyStartIndex); if (parts.length < 3) { return null; } - const channel = normalizeThreadSuffixChannelHint(parts[0]); + const channel = normalizeSessionConversationChannel(parts[0]); const kind = parts[1]?.trim().toLowerCase(); if (!channel || (kind !== "group" && kind !== "channel")) { return null; } - const joined = parts.slice(2).join(":"); - const { baseSessionKey, threadId } = parseThreadSessionSuffix(joined, { - channelHint: channel, - }); - const id = (baseSessionKey ?? joined).trim(); - if (!id) { + const rawId = parts.slice(2).join(":").trim(); + const prefix = rawParts + .slice(0, bodyStartIndex + 2) + .join(":") + .trim(); + if (!rawId || !prefix) { return null; } - return { channel, kind, id, threadId }; + return { channel, kind, rawId, prefix }; } export function resolveThreadParentSessionKey( diff --git a/src/test-utils/session-conversation-registry.ts b/src/test-utils/session-conversation-registry.ts new file mode 100644 index 00000000000..2f11bc8b2d1 --- /dev/null +++ b/src/test-utils/session-conversation-registry.ts @@ -0,0 +1,138 @@ +import { loadBundledPluginPublicSurfaceSync } from "./bundled-plugin-public-surface.js"; +import { createTestRegistry } from "./channel-plugins.js"; + +type SessionConversationSurface = { + resolveSessionConversation?: (params: { kind: "group" | "channel"; rawId: string }) => { + id: string; + threadId?: string | null; + baseConversationId?: string | null; + parentConversationCandidates?: string[]; + } | null; +}; + +function loadSessionConversationSurface(pluginId: string) { + return loadBundledPluginPublicSurfaceSync({ + pluginId, + artifactBasename: "session-key-api.js", + }).resolveSessionConversation; +} + +const resolveTelegramSessionConversation = loadSessionConversationSurface("telegram"); +const resolveFeishuSessionConversation = loadSessionConversationSurface("feishu"); + +export function createSessionConversationTestRegistry() { + return createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + messaging: { + resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "slack", + source: "test", + plugin: { + id: "slack", + meta: { + id: "slack", + label: "Slack", + selectionLabel: "Slack", + docsPath: "/channels/slack", + blurb: "Slack test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + messaging: { + resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "matrix", + source: "test", + plugin: { + id: "matrix", + meta: { + id: "matrix", + label: "Matrix", + selectionLabel: "Matrix", + docsPath: "/channels/matrix", + blurb: "Matrix test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + messaging: { + resolveSessionTarget: ({ id }: { id: string }) => `channel:${id}`, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "telegram", + source: "test", + plugin: { + id: "telegram", + meta: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram test stub.", + }, + capabilities: { chatTypes: ["direct", "group", "thread"] }, + messaging: { + normalizeTarget: (raw: string) => raw.replace(/^group:/, ""), + resolveSessionConversation: resolveTelegramSessionConversation, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "feishu", + source: "test", + plugin: { + id: "feishu", + meta: { + id: "feishu", + label: "Feishu", + selectionLabel: "Feishu", + docsPath: "/channels/feishu", + blurb: "Feishu test stub.", + }, + capabilities: { chatTypes: ["direct", "group", "thread"] }, + messaging: { + normalizeTarget: (raw: string) => raw.replace(/^group:/, ""), + resolveSessionConversation: resolveFeishuSessionConversation, + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + ]); +}