diff --git a/docs/.generated/plugin-sdk-api-baseline.json b/docs/.generated/plugin-sdk-api-baseline.json index 4c60c92aaed..6d5b9d65fd7 100644 --- a/docs/.generated/plugin-sdk-api-baseline.json +++ b/docs/.generated/plugin-sdk-api-baseline.json @@ -73,7 +73,7 @@ "exportName": "ChannelAccountSnapshot", "kind": "type", "source": { - "line": 147, + "line": 154, "path": "src/channels/plugins/types.core.ts" } }, @@ -100,7 +100,7 @@ "exportName": "ChannelCapabilities", "kind": "type", "source": { - "line": 233, + "line": 240, "path": "src/channels/plugins/types.core.ts" } }, @@ -127,7 +127,7 @@ "exportName": "ChannelConfiguredBindingConversationRef", "kind": "type", "source": { - "line": 776, + "line": 788, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -136,7 +136,7 @@ "exportName": "ChannelConfiguredBindingMatch", "kind": "type", "source": { - "line": 781, + "line": 793, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -145,7 +145,7 @@ "exportName": "ChannelConfiguredBindingProvider", "kind": "type", "source": { - "line": 797, + "line": 809, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -154,7 +154,7 @@ "exportName": "ChannelGatewayContext", "kind": "type", "source": { - "line": 285, + "line": 287, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -172,7 +172,7 @@ "exportName": "ChannelMessageActionAdapter", "kind": "type", "source": { - "line": 580, + "line": 592, "path": "src/channels/plugins/types.core.ts" } }, @@ -181,7 +181,7 @@ "exportName": "ChannelMessageActionContext", "kind": "type", "source": { - "line": 544, + "line": 556, "path": "src/channels/plugins/types.core.ts" } }, @@ -208,7 +208,7 @@ "exportName": "ChannelSetupAdapter", "kind": "type", "source": { - "line": 63, + "line": 65, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1856,7 +1856,7 @@ "exportName": "BaseProbeResult", "kind": "type", "source": { - "line": 630, + "line": 642, "path": "src/channels/plugins/types.core.ts" } }, @@ -1865,7 +1865,7 @@ "exportName": "BaseTokenResolution", "kind": "type", "source": { - "line": 636, + "line": 648, "path": "src/channels/plugins/types.core.ts" } }, @@ -1874,7 +1874,7 @@ "exportName": "ChannelAccountSnapshot", "kind": "type", "source": { - "line": 147, + "line": 154, "path": "src/channels/plugins/types.core.ts" } }, @@ -1892,7 +1892,7 @@ "exportName": "ChannelApprovalAdapter", "kind": "type", "source": { - "line": 714, + "line": 726, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1901,7 +1901,7 @@ "exportName": "ChannelApprovalCapability", "kind": "type", "source": { - "line": 703, + "line": 715, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1910,7 +1910,7 @@ "exportName": "ChannelCommandConversationContext", "kind": "type", "source": { - "line": 785, + "line": 797, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1919,16 +1919,25 @@ "exportName": "ChannelDirectoryAdapter", "kind": "type", "source": { - "line": 472, + "line": 475, "path": "src/channels/plugins/types.adapters.ts" } }, + { + "declaration": "export type ChannelDirectoryEntry = ChannelDirectoryEntry;", + "exportName": "ChannelDirectoryEntry", + "kind": "type", + "source": { + "line": 543, + "path": "src/channels/plugins/types.core.ts" + } + }, { "declaration": "export type ChannelDoctorAdapter = ChannelDoctorAdapter;", "exportName": "ChannelDoctorAdapter", "kind": "type", "source": { - "line": 560, + "line": 565, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1937,7 +1946,7 @@ "exportName": "ChannelDoctorConfigMutation", "kind": "type", "source": { - "line": 540, + "line": 543, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1946,7 +1955,7 @@ "exportName": "ChannelDoctorEmptyAllowlistAccountContext", "kind": "type", "source": { - "line": 551, + "line": 556, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1955,7 +1964,16 @@ "exportName": "ChannelDoctorSequenceResult", "kind": "type", "source": { - "line": 546, + "line": 551, + "path": "src/channels/plugins/types.adapters.ts" + } + }, + { + "declaration": "export type ChannelGatewayContext = ChannelGatewayContext;", + "exportName": "ChannelGatewayContext", + "kind": "type", + "source": { + "line": 287, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -1964,7 +1982,16 @@ "exportName": "ChannelGroupContext", "kind": "type", "source": { - "line": 219, + "line": 226, + "path": "src/channels/plugins/types.core.ts" + } + }, + { + "declaration": "export type ChannelLegacyStateMigrationPlan = ChannelLegacyStateMigrationPlan;", + "exportName": "ChannelLegacyStateMigrationPlan", + "kind": "type", + "source": { + "line": 123, "path": "src/channels/plugins/types.core.ts" } }, @@ -1973,7 +2000,7 @@ "exportName": "ChannelMessageActionAdapter", "kind": "type", "source": { - "line": 580, + "line": 592, "path": "src/channels/plugins/types.core.ts" } }, @@ -1982,7 +2009,7 @@ "exportName": "ChannelMessageActionContext", "kind": "type", "source": { - "line": 544, + "line": 556, "path": "src/channels/plugins/types.core.ts" } }, @@ -2022,6 +2049,33 @@ "path": "src/channels/plugins/types.core.ts" } }, + { + "declaration": "export type ChannelOutboundAdapter = ChannelOutboundAdapter;", + "exportName": "ChannelOutboundAdapter", + "kind": "type", + "source": { + "line": 178, + "path": "src/channels/plugins/types.adapters.ts" + } + }, + { + "declaration": "export type ChannelResolveKind = ChannelResolveKind;", + "exportName": "ChannelResolveKind", + "kind": "type", + "source": { + "line": 486, + "path": "src/channels/plugins/types.adapters.ts" + } + }, + { + "declaration": "export type ChannelResolveResult = ChannelResolveResult;", + "exportName": "ChannelResolveResult", + "kind": "type", + "source": { + "line": 488, + "path": "src/channels/plugins/types.adapters.ts" + } + }, { "declaration": "export type ChannelStatusIssue = ChannelStatusIssue;", "exportName": "ChannelStatusIssue", @@ -2036,7 +2090,7 @@ "exportName": "ChannelStructuredComponents", "kind": "type", "source": { - "line": 291, + "line": 298, "path": "src/channels/plugins/types.core.ts" } }, @@ -2045,7 +2099,7 @@ "exportName": "ChannelThreadingContext", "kind": "type", "source": { - "line": 368, + "line": 375, "path": "src/channels/plugins/types.core.ts" } }, @@ -2054,7 +2108,16 @@ "exportName": "ChannelThreadingToolContext", "kind": "type", "source": { - "line": 382, + "line": 389, + "path": "src/channels/plugins/types.core.ts" + } + }, + { + "declaration": "export type ChannelToolSend = ChannelToolSend;", + "exportName": "ChannelToolSend", + "kind": "type", + "source": { + "line": 585, "path": "src/channels/plugins/types.core.ts" } } @@ -2074,7 +2137,7 @@ "exportName": "createChannelPairingChallengeIssuer", "kind": "function", "source": { - "line": 22, + "line": 23, "path": "src/plugin-sdk/channel-pairing.ts" } }, @@ -2083,7 +2146,7 @@ "exportName": "createChannelPairingController", "kind": "function", "source": { - "line": 40, + "line": 41, "path": "src/plugin-sdk/channel-pairing.ts" } }, @@ -2114,6 +2177,15 @@ "path": "src/channels/plugins/pairing-adapters.ts" } }, + { + "declaration": "export function readChannelAllowFromStore(channel: ChannelId, env?: ProcessEnv, accountId?: string | undefined): Promise;", + "exportName": "readChannelAllowFromStore", + "kind": "function", + "source": { + "line": 570, + "path": "src/pairing/pairing-store.ts" + } + }, { "declaration": "export function readChannelAllowFromStoreSync(channel: ChannelId, env?: ProcessEnv, accountId?: string | undefined): string[];", "exportName": "readChannelAllowFromStoreSync", @@ -2123,12 +2195,21 @@ "path": "src/pairing/pairing-store.ts" } }, + { + "declaration": "export function resolveChannelAllowFromPath(channel: ChannelId, env?: ProcessEnv, accountId?: string | undefined): string;", + "exportName": "resolveChannelAllowFromPath", + "kind": "function", + "source": { + "line": 107, + "path": "src/pairing/pairing-store.ts" + } + }, { "declaration": "export type ChannelPairingController = ChannelPairingController;", "exportName": "ChannelPairingController", "kind": "type", "source": { - "line": 15, + "line": 16, "path": "src/plugin-sdk/channel-pairing.ts" } } @@ -2148,16 +2229,43 @@ "exportName": "createChannelReplyPipeline", "kind": "function", "source": { - "line": 20, + "line": 25, "path": "src/plugin-sdk/channel-reply-pipeline.ts" } }, + { + "declaration": "export function createReplyPrefixContext(params: { cfg: OpenClawConfig; agentId: string; channel?: string | undefined; accountId?: string | undefined; }): ReplyPrefixContextBundle;", + "exportName": "createReplyPrefixContext", + "kind": "function", + "source": { + "line": 23, + "path": "src/channels/reply-prefix.ts" + } + }, + { + "declaration": "export function createReplyPrefixOptions(params: { cfg: OpenClawConfig; agentId: string; channel?: string | undefined; accountId?: string | undefined; }): ReplyPrefixOptions;", + "exportName": "createReplyPrefixOptions", + "kind": "function", + "source": { + "line": 53, + "path": "src/channels/reply-prefix.ts" + } + }, + { + "declaration": "export function createTypingCallbacks(params: CreateTypingCallbacksParams): TypingCallbacks;", + "exportName": "createTypingCallbacks", + "kind": "function", + "source": { + "line": 23, + "path": "src/channels/typing.ts" + } + }, { "declaration": "export type ChannelReplyPipeline = ChannelReplyPipeline;", "exportName": "ChannelReplyPipeline", "kind": "type", "source": { - "line": 16, + "line": 20, "path": "src/plugin-sdk/channel-reply-pipeline.ts" } }, @@ -2175,7 +2283,7 @@ "exportName": "ReplyPrefixContext", "kind": "type", "source": { - "line": 12, + "line": 15, "path": "src/plugin-sdk/channel-reply-pipeline.ts" } }, @@ -2438,7 +2546,7 @@ "exportName": "BaseProbeResult", "kind": "type", "source": { - "line": 630, + "line": 642, "path": "src/channels/plugins/types.core.ts" } }, @@ -2447,7 +2555,7 @@ "exportName": "BaseTokenResolution", "kind": "type", "source": { - "line": 636, + "line": 648, "path": "src/channels/plugins/types.core.ts" } }, @@ -2456,7 +2564,7 @@ "exportName": "ChannelAccountSnapshot", "kind": "type", "source": { - "line": 147, + "line": 154, "path": "src/channels/plugins/types.core.ts" } }, @@ -2474,7 +2582,7 @@ "exportName": "ChannelActionAvailabilityState", "kind": "type", "source": { - "line": 32, + "line": 34, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2483,7 +2591,7 @@ "exportName": "ChannelAgentPromptAdapter", "kind": "type", "source": { - "line": 511, + "line": 523, "path": "src/channels/plugins/types.core.ts" } }, @@ -2510,7 +2618,7 @@ "exportName": "ChannelAllowlistAdapter", "kind": "type", "source": { - "line": 720, + "line": 732, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2519,7 +2627,7 @@ "exportName": "ChannelApprovalAdapter", "kind": "type", "source": { - "line": 714, + "line": 726, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2528,7 +2636,7 @@ "exportName": "ChannelApprovalCapability", "kind": "type", "source": { - "line": 703, + "line": 715, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2537,7 +2645,7 @@ "exportName": "ChannelApprovalForwardTarget", "kind": "type", "source": { - "line": 39, + "line": 41, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2546,7 +2654,7 @@ "exportName": "ChannelApprovalInitiatingSurfaceState", "kind": "type", "source": { - "line": 37, + "line": 39, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2555,7 +2663,7 @@ "exportName": "ChannelAuthAdapter", "kind": "type", "source": { - "line": 409, + "line": 412, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2564,7 +2672,7 @@ "exportName": "ChannelCapabilities", "kind": "type", "source": { - "line": 233, + "line": 240, "path": "src/channels/plugins/types.core.ts" } }, @@ -2573,7 +2681,7 @@ "exportName": "ChannelCapabilitiesDiagnostics", "kind": "type", "source": { - "line": 54, + "line": 56, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2582,7 +2690,7 @@ "exportName": "ChannelCapabilitiesDisplayLine", "kind": "type", "source": { - "line": 49, + "line": 51, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2591,7 +2699,7 @@ "exportName": "ChannelCapabilitiesDisplayTone", "kind": "type", "source": { - "line": 47, + "line": 49, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2600,7 +2708,7 @@ "exportName": "ChannelCommandAdapter", "kind": "type", "source": { - "line": 510, + "line": 513, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2609,7 +2717,7 @@ "exportName": "ChannelCommandConversationContext", "kind": "type", "source": { - "line": 785, + "line": 797, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2618,7 +2726,7 @@ "exportName": "ChannelConfigAdapter", "kind": "type", "source": { - "line": 98, + "line": 100, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2627,7 +2735,7 @@ "exportName": "ChannelConfiguredBindingConversationRef", "kind": "type", "source": { - "line": 776, + "line": 788, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2636,7 +2744,7 @@ "exportName": "ChannelConfiguredBindingMatch", "kind": "type", "source": { - "line": 781, + "line": 793, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2645,7 +2753,7 @@ "exportName": "ChannelConfiguredBindingProvider", "kind": "type", "source": { - "line": 797, + "line": 809, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2654,7 +2762,7 @@ "exportName": "ChannelConversationBindingSupport", "kind": "type", "source": { - "line": 814, + "line": 826, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2663,7 +2771,7 @@ "exportName": "ChannelDirectoryAdapter", "kind": "type", "source": { - "line": 472, + "line": 475, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2672,7 +2780,7 @@ "exportName": "ChannelDirectoryEntry", "kind": "type", "source": { - "line": 531, + "line": 543, "path": "src/channels/plugins/types.core.ts" } }, @@ -2681,7 +2789,7 @@ "exportName": "ChannelDirectoryEntryKind", "kind": "type", "source": { - "line": 529, + "line": 541, "path": "src/channels/plugins/types.core.ts" } }, @@ -2690,7 +2798,7 @@ "exportName": "ChannelElevatedAdapter", "kind": "type", "source": { - "line": 503, + "line": 506, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2699,7 +2807,7 @@ "exportName": "ChannelGatewayAdapter", "kind": "type", "source": { - "line": 393, + "line": 395, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2708,7 +2816,7 @@ "exportName": "ChannelGatewayContext", "kind": "type", "source": { - "line": 285, + "line": 287, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2717,7 +2825,7 @@ "exportName": "ChannelGroupAdapter", "kind": "type", "source": { - "line": 130, + "line": 132, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2726,7 +2834,7 @@ "exportName": "ChannelGroupContext", "kind": "type", "source": { - "line": 219, + "line": 226, "path": "src/channels/plugins/types.core.ts" } }, @@ -2735,7 +2843,7 @@ "exportName": "ChannelHeartbeatAdapter", "kind": "type", "source": { - "line": 435, + "line": 438, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2762,7 +2870,7 @@ "exportName": "ChannelLifecycleAdapter", "kind": "type", "source": { - "line": 593, + "line": 599, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2771,7 +2879,7 @@ "exportName": "ChannelLoginWithQrStartResult", "kind": "type", "source": { - "line": 364, + "line": 366, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2780,7 +2888,7 @@ "exportName": "ChannelLoginWithQrWaitResult", "kind": "type", "source": { - "line": 369, + "line": 371, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2789,7 +2897,7 @@ "exportName": "ChannelLogoutContext", "kind": "type", "source": { - "line": 374, + "line": 376, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2798,7 +2906,7 @@ "exportName": "ChannelLogoutResult", "kind": "type", "source": { - "line": 358, + "line": 360, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2807,7 +2915,7 @@ "exportName": "ChannelLogSink", "kind": "type", "source": { - "line": 212, + "line": 219, "path": "src/channels/plugins/types.core.ts" } }, @@ -2816,7 +2924,7 @@ "exportName": "ChannelMentionAdapter", "kind": "type", "source": { - "line": 263, + "line": 270, "path": "src/channels/plugins/types.core.ts" } }, @@ -2825,7 +2933,7 @@ "exportName": "ChannelMessageActionAdapter", "kind": "type", "source": { - "line": 580, + "line": 592, "path": "src/channels/plugins/types.core.ts" } }, @@ -2834,7 +2942,7 @@ "exportName": "ChannelMessageActionContext", "kind": "type", "source": { - "line": 544, + "line": 556, "path": "src/channels/plugins/types.core.ts" } }, @@ -2888,7 +2996,7 @@ "exportName": "ChannelMessagingAdapter", "kind": "type", "source": { - "line": 398, + "line": 405, "path": "src/channels/plugins/types.core.ts" } }, @@ -2897,7 +3005,7 @@ "exportName": "ChannelMeta", "kind": "type", "source": { - "line": 124, + "line": 131, "path": "src/channels/plugins/types.core.ts" } }, @@ -2906,7 +3014,7 @@ "exportName": "ChannelOutboundAdapter", "kind": "type", "source": { - "line": 176, + "line": 178, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2915,7 +3023,7 @@ "exportName": "ChannelOutboundContext", "kind": "type", "source": { - "line": 136, + "line": 138, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2924,7 +3032,7 @@ "exportName": "ChannelOutboundPayloadHint", "kind": "type", "source": { - "line": 161, + "line": 163, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2942,7 +3050,7 @@ "exportName": "ChannelOutboundTargetRef", "kind": "type", "source": { - "line": 165, + "line": 167, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2951,7 +3059,7 @@ "exportName": "ChannelPairingAdapter", "kind": "type", "source": { - "line": 382, + "line": 384, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2969,7 +3077,7 @@ "exportName": "ChannelPollContext", "kind": "type", "source": { - "line": 618, + "line": 630, "path": "src/channels/plugins/types.core.ts" } }, @@ -2978,7 +3086,7 @@ "exportName": "ChannelPollResult", "kind": "type", "source": { - "line": 609, + "line": 621, "path": "src/channels/plugins/types.core.ts" } }, @@ -2987,7 +3095,7 @@ "exportName": "ChannelResolveKind", "kind": "type", "source": { - "line": 483, + "line": 486, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -2996,7 +3104,7 @@ "exportName": "ChannelResolverAdapter", "kind": "type", "source": { - "line": 493, + "line": 496, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -3005,7 +3113,7 @@ "exportName": "ChannelResolveResult", "kind": "type", "source": { - "line": 485, + "line": 488, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -3014,7 +3122,7 @@ "exportName": "ChannelSecurityAdapter", "kind": "type", "source": { - "line": 884, + "line": 899, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -3023,7 +3131,7 @@ "exportName": "ChannelSecurityContext", "kind": "type", "source": { - "line": 257, + "line": 264, "path": "src/channels/plugins/types.core.ts" } }, @@ -3032,7 +3140,7 @@ "exportName": "ChannelSecurityDmPolicy", "kind": "type", "source": { - "line": 248, + "line": 255, "path": "src/channels/plugins/types.core.ts" } }, @@ -3041,7 +3149,7 @@ "exportName": "ChannelSetupAdapter", "kind": "type", "source": { - "line": 63, + "line": 65, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -3059,7 +3167,7 @@ "exportName": "ChannelStatusAdapter", "kind": "type", "source": { - "line": 230, + "line": 232, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -3077,7 +3185,7 @@ "exportName": "ChannelStreamingAdapter", "kind": "type", "source": { - "line": 282, + "line": 289, "path": "src/channels/plugins/types.core.ts" } }, @@ -3086,7 +3194,7 @@ "exportName": "ChannelStructuredComponents", "kind": "type", "source": { - "line": 291, + "line": 298, "path": "src/channels/plugins/types.core.ts" } }, @@ -3095,7 +3203,7 @@ "exportName": "ChannelThreadingAdapter", "kind": "type", "source": { - "line": 325, + "line": 332, "path": "src/channels/plugins/types.core.ts" } }, @@ -3104,7 +3212,7 @@ "exportName": "ChannelThreadingContext", "kind": "type", "source": { - "line": 368, + "line": 375, "path": "src/channels/plugins/types.core.ts" } }, @@ -3113,7 +3221,7 @@ "exportName": "ChannelThreadingToolContext", "kind": "type", "source": { - "line": 382, + "line": 389, "path": "src/channels/plugins/types.core.ts" } }, @@ -3122,7 +3230,7 @@ "exportName": "ChannelToolSend", "kind": "type", "source": { - "line": 573, + "line": 585, "path": "src/channels/plugins/types.core.ts" } }, @@ -3295,7 +3403,7 @@ "exportName": "setSetupChannelEnabled", "kind": "function", "source": { - "line": 882, + "line": 874, "path": "src/channels/plugins/setup-wizard-helpers.ts" } }, @@ -3322,7 +3430,7 @@ "exportName": "ChannelSetupAdapter", "kind": "type", "source": { - "line": 63, + "line": 65, "path": "src/channels/plugins/types.adapters.ts" } }, @@ -3331,7 +3439,7 @@ "exportName": "ChannelSetupDmPolicy", "kind": "type", "source": { - "line": 93, + "line": 90, "path": "src/channels/plugins/setup-wizard-types.ts" } }, @@ -4010,7 +4118,7 @@ "exportName": "applyAccountNameToChannelSection", "kind": "function", "source": { - "line": 34, + "line": 35, "path": "src/channels/plugins/setup-helpers.ts" } }, @@ -4037,7 +4145,7 @@ "exportName": "buildChannelOutboundSessionRoute", "kind": "function", "source": { - "line": 193, + "line": 226, "path": "src/plugin-sdk/core.ts" } }, @@ -4077,12 +4185,21 @@ "path": "src/channels/plugins/config-helpers.ts" } }, + { + "declaration": "export function createActionGate>(actions: T | undefined): ActionGate;", + "exportName": "createActionGate", + "kind": "function", + "source": { + "line": 46, + "path": "src/agents/tools/common.ts" + } + }, { "declaration": "export function createChannelPluginBase(params: CreateChannelPluginBaseOptions): CreatedChannelPluginBase;", "exportName": "createChannelPluginBase", "kind": "function", "source": { - "line": 558, + "line": 591, "path": "src/plugin-sdk/core.ts" } }, @@ -4091,7 +4208,7 @@ "exportName": "createChatChannelPlugin", "kind": "function", "source": { - "line": 531, + "line": 564, "path": "src/plugin-sdk/core.ts" } }, @@ -4109,7 +4226,7 @@ "exportName": "createSubsystemLogger", "kind": "function", "source": { - "line": 308, + "line": 302, "path": "src/logging/subsystem.ts" } }, @@ -4118,7 +4235,7 @@ "exportName": "defineChannelPluginEntry", "kind": "function", "source": { - "line": 291, + "line": 324, "path": "src/plugin-sdk/core.ts" } }, @@ -4136,7 +4253,7 @@ "exportName": "defineSetupPluginEntry", "kind": "function", "source": { - "line": 334, + "line": 367, "path": "src/plugin-sdk/core.ts" } }, @@ -4176,6 +4293,15 @@ "path": "src/plugin-sdk/keyed-async-queue.ts" } }, + { + "declaration": "export function ensureConfiguredAcpBindingReady(params: { cfg: OpenClawConfig; configuredBinding: ResolvedConfiguredAcpBinding | null; }): Promise<{ ok: true; } | { ok: false; error: string; }>;", + "exportName": "ensureConfiguredAcpBindingReady", + "kind": "function", + "source": { + "line": 110, + "path": "src/acp/persistent-bindings.lifecycle.ts" + } + }, { "declaration": "export function formatPairingApproveHint(channelId: string): string;", "exportName": "formatPairingApproveHint", @@ -4185,6 +4311,15 @@ "path": "src/channels/plugins/helpers.ts" } }, + { + "declaration": "export function formatZonedTimestamp(date: Date, options?: FormatZonedTimestampOptions | undefined): string | undefined;", + "exportName": "formatZonedTimestamp", + "kind": "function", + "source": { + "line": 57, + "path": "src/infra/format-time/format-datetime.ts" + } + }, { "declaration": "export function generateSecureToken(bytes?: number): string;", "exportName": "generateSecureToken", @@ -4221,6 +4356,24 @@ "path": "src/config/types.secrets.ts" } }, + { + "declaration": "export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[] | undefined): boolean;", + "exportName": "isTrustedProxyAddress", + "kind": "function", + "source": { + "line": 136, + "path": "src/gateway/net.ts" + } + }, + { + "declaration": "export function jsonResult(payload: unknown): AgentToolResult;", + "exportName": "jsonResult", + "kind": "function", + "source": { + "line": 256, + "path": "src/agents/tools/common.ts" + } + }, { "declaration": "export function loadSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): SecretFileReadResult;", "exportName": "loadSecretFileSync", @@ -4235,7 +4388,7 @@ "exportName": "migrateBaseNameToDefaultAccount", "kind": "function", "source": { - "line": 93, + "line": 94, "path": "src/channels/plugins/setup-helpers.ts" } }, @@ -4284,6 +4437,33 @@ "path": "src/channels/plugins/helpers.ts" } }, + { + "declaration": "export function parseStrictPositiveInteger(value: unknown): number | undefined;", + "exportName": "parseStrictPositiveInteger", + "kind": "function", + "source": { + "line": 34, + "path": "src/infra/parse-finite-number.ts" + } + }, + { + "declaration": "export function readNumberParam(params: Record, key: string, options?: { required?: boolean | undefined; label?: string | undefined; integer?: boolean | undefined; strict?: boolean | undefined; }): number | undefined;", + "exportName": "readNumberParam", + "kind": "function", + "source": { + "line": 117, + "path": "src/agents/tools/common.ts" + } + }, + { + "declaration": "export function readReactionParams(params: Record, options: { emojiKey?: string | undefined; removeKey?: string | undefined; removeErrorMessage: string; }): ReactionParams;", + "exportName": "readReactionParams", + "kind": "function", + "source": { + "line": 197, + "path": "src/agents/tools/common.ts" + } + }, { "declaration": "export function readSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): string;", "exportName": "readSecretFileSync", @@ -4293,6 +4473,42 @@ "path": "src/infra/secret-file.ts" } }, + { + "declaration": "export function readStringArrayParam(params: Record, key: string, options: StringParamOptions & { required: true; }): string[];\nexport function readStringArrayParam(params: Record, key: string, options?: StringParamOptions | undefined): string[] | undefined;", + "exportName": "readStringArrayParam", + "kind": "function", + "source": { + "line": 145, + "path": "src/agents/tools/common.ts" + } + }, + { + "declaration": "export function readStringParam(params: Record, key: string, options: StringParamOptions & { required: true; }): string;\nexport function readStringParam(params: Record, key: string, options?: StringParamOptions | undefined): string | undefined;", + "exportName": "readStringParam", + "kind": "function", + "source": { + "line": 62, + "path": "src/agents/tools/common.ts" + } + }, + { + "declaration": "export function resolveClientIp(params: { remoteAddr?: string | undefined; forwardedFor?: string | undefined; realIp?: string | undefined; trustedProxies?: string[] | undefined; allowRealIpFallback?: boolean | undefined; }): string | undefined;", + "exportName": "resolveClientIp", + "kind": "function", + "source": { + "line": 151, + "path": "src/gateway/net.ts" + } + }, + { + "declaration": "export function resolveConfiguredAcpBindingRecord(params: { cfg: OpenClawConfig; channel: string; accountId: string; conversationId: string; parentConversationId?: string | undefined; }): ResolvedConfiguredAcpBinding | null;", + "exportName": "resolveConfiguredAcpBindingRecord", + "kind": "function", + "source": { + "line": 15, + "path": "src/acp/persistent-bindings.resolve.ts" + } + }, { "declaration": "export function resolveGatewayBindUrl(params: { bind?: string | undefined; customBindHost?: string | undefined; scheme: \"ws\" | \"wss\"; port: number; pickTailnetHost: () => string | null; pickLanHost: () => string | null; }): GatewayBindUrlResult;", "exportName": "resolveGatewayBindUrl", @@ -4361,7 +4577,7 @@ "exportName": "stripChannelTargetPrefix", "kind": "function", "source": { - "line": 173, + "line": 206, "path": "src/plugin-sdk/core.ts" } }, @@ -4370,7 +4586,7 @@ "exportName": "stripTargetKindPrefix", "kind": "function", "source": { - "line": 185, + "line": 218, "path": "src/plugin-sdk/core.ts" } }, @@ -4401,6 +4617,15 @@ "path": "src/infra/secret-file.ts" } }, + { + "declaration": "export type AllowlistMatch = AllowlistMatch;", + "exportName": "AllowlistMatch", + "kind": "type", + "source": { + "line": 13, + "path": "src/channels/allowlist-match.ts" + } + }, { "declaration": "export type AnyAgentTool = AnyAgentTool;", "exportName": "AnyAgentTool", @@ -4410,6 +4635,24 @@ "path": "src/agents/tools/common.ts" } }, + { + "declaration": "export type BaseProbeResult = BaseProbeResult;", + "exportName": "BaseProbeResult", + "kind": "type", + "source": { + "line": 642, + "path": "src/channels/plugins/types.core.ts" + } + }, + { + "declaration": "export type ChannelAccountSnapshot = ChannelAccountSnapshot;", + "exportName": "ChannelAccountSnapshot", + "kind": "type", + "source": { + "line": 154, + "path": "src/channels/plugins/types.core.ts" + } + }, { "declaration": "export type ChannelConfigUiHint = ChannelConfigUiHint;", "exportName": "ChannelConfigUiHint", @@ -4419,30 +4662,75 @@ "path": "src/channels/plugins/types.plugin.ts" } }, + { + "declaration": "export type ChannelDirectoryEntry = ChannelDirectoryEntry;", + "exportName": "ChannelDirectoryEntry", + "kind": "type", + "source": { + "line": 543, + "path": "src/channels/plugins/types.core.ts" + } + }, + { + "declaration": "export type ChannelGroupContext = ChannelGroupContext;", + "exportName": "ChannelGroupContext", + "kind": "type", + "source": { + "line": 226, + "path": "src/channels/plugins/types.core.ts" + } + }, { "declaration": "export type ChannelMessageActionContext = ChannelMessageActionContext;", "exportName": "ChannelMessageActionContext", "kind": "type", "source": { - "line": 544, + "line": 556, "path": "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\";", + "exportName": "ChannelMessageActionName", + "kind": "type", + "source": { + "line": 6, + "path": "src/channels/plugins/types.ts" + } + }, { "declaration": "export type ChannelMessagingAdapter = ChannelMessagingAdapter;", "exportName": "ChannelMessagingAdapter", "kind": "type", "source": { - "line": 398, + "line": 405, "path": "src/channels/plugins/types.core.ts" } }, + { + "declaration": "export type ChannelMeta = ChannelMeta;", + "exportName": "ChannelMeta", + "kind": "type", + "source": { + "line": 131, + "path": "src/channels/plugins/types.core.ts" + } + }, + { + "declaration": "export type ChannelOutboundAdapter = ChannelOutboundAdapter;", + "exportName": "ChannelOutboundAdapter", + "kind": "type", + "source": { + "line": 178, + "path": "src/channels/plugins/types.adapters.ts" + } + }, { "declaration": "export type ChannelOutboundSessionRoute = ChannelOutboundSessionRoute;", "exportName": "ChannelOutboundSessionRoute", "kind": "type", "source": { - "line": 312, + "line": 319, "path": "src/channels/plugins/types.core.ts" } }, @@ -4451,7 +4739,7 @@ "exportName": "ChannelOutboundSessionRouteParams", "kind": "type", "source": { - "line": 168, + "line": 201, "path": "src/plugin-sdk/core.ts" } }, @@ -4464,6 +4752,24 @@ "path": "src/channels/plugins/types.plugin.ts" } }, + { + "declaration": "export type ChannelSetupInput = ChannelSetupInput;", + "exportName": "ChannelSetupInput", + "kind": "type", + "source": { + "line": 64, + "path": "src/channels/plugins/types.core.ts" + } + }, + { + "declaration": "export type ChatType = ChatType;", + "exportName": "ChatType", + "kind": "type", + "source": { + "line": 1, + "path": "src/channels/chat-type.ts" + } + }, { "declaration": "export type GatewayBindUrlResult = GatewayBindUrlResult;", "exportName": "GatewayBindUrlResult", @@ -4482,6 +4788,15 @@ "path": "src/gateway/server-methods/types.ts" } }, + { + "declaration": "export type HistoryEntry = HistoryEntry;", + "exportName": "HistoryEntry", + "kind": "type", + "source": { + "line": 30, + "path": "src/auto-reply/reply/history.ts" + } + }, { "declaration": "export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;", "exportName": "MediaUnderstandingProviderPlugin", @@ -4491,6 +4806,15 @@ "path": "src/plugins/types.ts" } }, + { + "declaration": "export type NormalizedLocation = NormalizedLocation;", + "exportName": "NormalizedLocation", + "kind": "type", + "source": { + "line": 3, + "path": "src/channels/location.ts" + } + }, { "declaration": "export type OpenClawConfig = OpenClawConfig;", "exportName": "OpenClawConfig", @@ -4572,6 +4896,15 @@ "path": "src/plugins/types.ts" } }, + { + "declaration": "export type OutboundIdentity = OutboundIdentity;", + "exportName": "OutboundIdentity", + "kind": "type", + "source": { + "line": 5, + "path": "src/infra/outbound/identity.ts" + } + }, { "declaration": "export type PluginCommandContext = PluginCommandContext;", "exportName": "PluginCommandContext", @@ -4599,6 +4932,15 @@ "path": "src/plugins/runtime/types.ts" } }, + { + "declaration": "export type PollInput = PollInput;", + "exportName": "PollInput", + "kind": "type", + "source": { + "line": 1, + "path": "src/polls.ts" + } + }, { "declaration": "export type ProviderAugmentModelCatalogContext = ProviderAugmentModelCatalogContext;", "exportName": "ProviderAugmentModelCatalogContext", @@ -4950,6 +5292,15 @@ "path": "src/plugins/types.ts" } }, + { + "declaration": "export type ReplyPayload = ReplyPayload;", + "exportName": "ReplyPayload", + "kind": "type", + "source": { + "line": 85, + "path": "src/auto-reply/types.ts" + } + }, { "declaration": "export type RoutePeer = RoutePeer;", "exportName": "RoutePeer", @@ -4968,6 +5319,15 @@ "path": "src/routing/resolve-route.ts" } }, + { + "declaration": "export type RuntimeLogger = RuntimeLogger;", + "exportName": "RuntimeLogger", + "kind": "type", + "source": { + "line": 7, + "path": "src/plugins/runtime/types-core.ts" + } + }, { "declaration": "export type SecretFileReadOptions = SecretFileReadOptions;", "exportName": "SecretFileReadOptions", @@ -5031,6 +5391,15 @@ "path": "src/infra/provider-usage.types.ts" } }, + { + "declaration": "export type WizardPrompter = WizardPrompter;", + "exportName": "WizardPrompter", + "kind": "type", + "source": { + "line": 37, + "path": "src/wizard/prompts.ts" + } + }, { "declaration": "export class KeyedAsyncQueue", "exportName": "KeyedAsyncQueue", @@ -6240,15 +6609,6 @@ "path": "src/agents/sandbox/test-fixtures.ts" } }, - { - "declaration": "export function createSlackOutboundPayloadHarness(params: { payload: ReplyPayload; sendResults?: { messageId: string; }[] | undefined; }): SlackOutboundPayloadHarness;", - "exportName": "createSlackOutboundPayloadHarness", - "kind": "function", - "source": { - "line": 26, - "path": "src/channels/plugins/contracts/slack-outbound-harness.ts" - } - }, { "declaration": "export function createTempHomeEnv(prefix: string): Promise;", "exportName": "createTempHomeEnv", @@ -6258,15 +6618,6 @@ "path": "src/test-utils/temp-home.ts" } }, - { - "declaration": "export function createWhatsAppPollFixture(): { cfg: OpenClawConfig; poll: { question: string; options: string[]; maxSelections: number; }; to: string; accountId: string; };", - "exportName": "createWhatsAppPollFixture", - "kind": "function", - "source": { - "line": 4, - "path": "src/test-helpers/whatsapp-outbound.ts" - } - }, { "declaration": "export function createWindowsCmdShimFixture(params: { shimPath: string; scriptPath: string; shimLine: string; }): Promise;", "exportName": "createWindowsCmdShimFixture", @@ -6294,15 +6645,6 @@ "path": "src/test-utils/auth-token-assertions.ts" } }, - { - "declaration": "export function expectWhatsAppPollSent(sendPollWhatsApp: MockInstance, params: { cfg: OpenClawConfig; poll: { question: string; options: string[]; maxSelections: number; }; to?: string | undefined; accountId?: string | undefined; }): void;", - "exportName": "expectWhatsAppPollSent", - "kind": "function", - "source": { - "line": 19, - "path": "src/test-helpers/whatsapp-outbound.ts" - } - }, { "declaration": "export function firstWrittenJsonArg(writeJson: MockCallsWithFirstArg): T | null;", "exportName": "firstWrittenJsonArg", @@ -6506,7 +6848,7 @@ "exportName": "setDefaultChannelPluginRegistryForTests", "kind": "function", "source": { - "line": 126, + "line": 41, "path": "src/commands/channel-test-registry.ts" } }, @@ -6623,7 +6965,7 @@ "exportName": "ChannelAccountSnapshot", "kind": "type", "source": { - "line": 147, + "line": 154, "path": "src/channels/plugins/types.core.ts" } }, @@ -6632,7 +6974,7 @@ "exportName": "ChannelGatewayContext", "kind": "type", "source": { - "line": 285, + "line": 287, "path": "src/channels/plugins/types.adapters.ts" } }, diff --git a/docs/.generated/plugin-sdk-api-baseline.jsonl b/docs/.generated/plugin-sdk-api-baseline.jsonl index 57dc21cd9bc..00bc53a8dac 100644 --- a/docs/.generated/plugin-sdk-api-baseline.jsonl +++ b/docs/.generated/plugin-sdk-api-baseline.jsonl @@ -6,22 +6,22 @@ {"declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"index","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":9,"sourcePath":"src/agents/tools/common.ts"} {"declaration":"export type BoundTaskFlowsRuntime = BoundTaskFlowsRuntime;","entrypoint":"index","exportName":"BoundTaskFlowsRuntime","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":74,"sourcePath":"src/plugins/runtime/runtime-tasks.ts"} {"declaration":"export type BoundTaskRunsRuntime = BoundTaskRunsRuntime;","entrypoint":"index","exportName":"BoundTaskRunsRuntime","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/runtime-tasks.ts"} -{"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"index","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":147,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"index","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":154,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAgentTool = ChannelAgentTool;","entrypoint":"index","exportName":"ChannelAgentTool","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":19,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelAgentToolFactory = ChannelAgentToolFactory;","entrypoint":"index","exportName":"ChannelAgentToolFactory","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":24,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelCapabilities = ChannelCapabilities;","entrypoint":"index","exportName":"ChannelCapabilities","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":233,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelCapabilities = ChannelCapabilities;","entrypoint":"index","exportName":"ChannelCapabilities","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":240,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelConfigSchema = ChannelConfigSchema;","entrypoint":"index","exportName":"ChannelConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":72,"sourcePath":"src/channels/plugins/types.plugin.ts"} {"declaration":"export type ChannelConfigUiHint = ChannelConfigUiHint;","entrypoint":"index","exportName":"ChannelConfigUiHint","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":41,"sourcePath":"src/channels/plugins/types.plugin.ts"} -{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"index","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":776,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"index","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":781,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":797,"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":285,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"index","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":788,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"index","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":793,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":809,"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":287,"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":580,"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":544,"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":592,"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":556,"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":82,"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"} +{"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"index","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":65,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelSetupInput = ChannelSetupInput;","entrypoint":"index","exportName":"ChannelSetupInput","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":64,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelSetupWizard = ChannelSetupWizard;","entrypoint":"index","exportName":"ChannelSetupWizard","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":253,"sourcePath":"src/channels/plugins/setup-wizard.ts"} {"declaration":"export type ChannelSetupWizardAllowFromEntry = ChannelSetupWizardAllowFromEntry;","entrypoint":"index","exportName":"ChannelSetupWizardAllowFromEntry","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":160,"sourcePath":"src/channels/plugins/setup-wizard.ts"} @@ -203,42 +203,54 @@ {"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>>; contextVisibility: z.ZodOptional>; 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>>; reactionLevel: z.ZodOptional>; 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>>; contextVisibility: z.ZodOptional>; 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>>; reactionLevel: z.ZodOptional>; 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":122,"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":630,"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":636,"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 BaseProbeResult = BaseProbeResult;","entrypoint":"channel-contract","exportName":"BaseProbeResult","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":642,"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":648,"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":154,"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 ChannelApprovalAdapter = ChannelApprovalAdapter;","entrypoint":"channel-contract","exportName":"ChannelApprovalAdapter","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":714,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelApprovalCapability = ChannelApprovalCapability;","entrypoint":"channel-contract","exportName":"ChannelApprovalCapability","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":703,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-contract","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":785,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelDirectoryAdapter = ChannelDirectoryAdapter;","entrypoint":"channel-contract","exportName":"ChannelDirectoryAdapter","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":472,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelDoctorAdapter = ChannelDoctorAdapter;","entrypoint":"channel-contract","exportName":"ChannelDoctorAdapter","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":560,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelDoctorConfigMutation = ChannelDoctorConfigMutation;","entrypoint":"channel-contract","exportName":"ChannelDoctorConfigMutation","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":540,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelDoctorEmptyAllowlistAccountContext = ChannelDoctorEmptyAllowlistAccountContext;","entrypoint":"channel-contract","exportName":"ChannelDoctorEmptyAllowlistAccountContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":551,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelDoctorSequenceResult = ChannelDoctorSequenceResult;","entrypoint":"channel-contract","exportName":"ChannelDoctorSequenceResult","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":546,"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":580,"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":544,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelApprovalAdapter = ChannelApprovalAdapter;","entrypoint":"channel-contract","exportName":"ChannelApprovalAdapter","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":726,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelApprovalCapability = ChannelApprovalCapability;","entrypoint":"channel-contract","exportName":"ChannelApprovalCapability","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":715,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-contract","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":797,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelDirectoryAdapter = ChannelDirectoryAdapter;","entrypoint":"channel-contract","exportName":"ChannelDirectoryAdapter","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":475,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelDirectoryEntry = ChannelDirectoryEntry;","entrypoint":"channel-contract","exportName":"ChannelDirectoryEntry","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":543,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelDoctorAdapter = ChannelDoctorAdapter;","entrypoint":"channel-contract","exportName":"ChannelDoctorAdapter","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":565,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelDoctorConfigMutation = ChannelDoctorConfigMutation;","entrypoint":"channel-contract","exportName":"ChannelDoctorConfigMutation","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":543,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelDoctorEmptyAllowlistAccountContext = ChannelDoctorEmptyAllowlistAccountContext;","entrypoint":"channel-contract","exportName":"ChannelDoctorEmptyAllowlistAccountContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":556,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelDoctorSequenceResult = ChannelDoctorSequenceResult;","entrypoint":"channel-contract","exportName":"ChannelDoctorSequenceResult","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":551,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext;","entrypoint":"channel-contract","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":287,"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":226,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelLegacyStateMigrationPlan = ChannelLegacyStateMigrationPlan;","entrypoint":"channel-contract","exportName":"ChannelLegacyStateMigrationPlan","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":123,"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":592,"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":556,"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"} {"declaration":"export type ChannelMessageToolSchemaContribution = ChannelMessageToolSchemaContribution;","entrypoint":"channel-contract","exportName":"ChannelMessageToolSchemaContribution","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":52,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelOutboundAdapter = ChannelOutboundAdapter;","entrypoint":"channel-contract","exportName":"ChannelOutboundAdapter","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":178,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelResolveKind = ChannelResolveKind;","entrypoint":"channel-contract","exportName":"ChannelResolveKind","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":486,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelResolveResult = ChannelResolveResult;","entrypoint":"channel-contract","exportName":"ChannelResolveResult","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":488,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelStatusIssue = ChannelStatusIssue;","entrypoint":"channel-contract","exportName":"ChannelStatusIssue","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":102,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelStructuredComponents = ChannelStructuredComponents;","entrypoint":"channel-contract","exportName":"ChannelStructuredComponents","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":291,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelThreadingContext = ChannelThreadingContext;","entrypoint":"channel-contract","exportName":"ChannelThreadingContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":368,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelThreadingToolContext = ChannelThreadingToolContext;","entrypoint":"channel-contract","exportName":"ChannelThreadingToolContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":382,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelStructuredComponents = ChannelStructuredComponents;","entrypoint":"channel-contract","exportName":"ChannelStructuredComponents","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":298,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelThreadingContext = ChannelThreadingContext;","entrypoint":"channel-contract","exportName":"ChannelThreadingContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":375,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelThreadingToolContext = ChannelThreadingToolContext;","entrypoint":"channel-contract","exportName":"ChannelThreadingToolContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":389,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelToolSend = ChannelToolSend;","entrypoint":"channel-contract","exportName":"ChannelToolSend","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":585,"sourcePath":"src/channels/plugins/types.core.ts"} {"category":"channel","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/channel-pairing.ts"} -{"declaration":"export function createChannelPairingChallengeIssuer(params: { channel: ChannelId; upsertPairingRequest: (params: { id: string; meta?: PairingMeta | undefined; }) => Promise<{ code: string; created: boolean; }>; }): (challenge: Omit<...>) => Promise<...>;","entrypoint":"channel-pairing","exportName":"createChannelPairingChallengeIssuer","importSpecifier":"openclaw/plugin-sdk/channel-pairing","kind":"function","recordType":"export","sourceLine":22,"sourcePath":"src/plugin-sdk/channel-pairing.ts"} -{"declaration":"export function createChannelPairingController(params: { core: PluginRuntime; channel: ChannelId; accountId: string; }): ChannelPairingController;","entrypoint":"channel-pairing","exportName":"createChannelPairingController","importSpecifier":"openclaw/plugin-sdk/channel-pairing","kind":"function","recordType":"export","sourceLine":40,"sourcePath":"src/plugin-sdk/channel-pairing.ts"} +{"declaration":"export function createChannelPairingChallengeIssuer(params: { channel: ChannelId; upsertPairingRequest: (params: { id: string; meta?: PairingMeta | undefined; }) => Promise<{ code: string; created: boolean; }>; }): (challenge: Omit<...>) => Promise<...>;","entrypoint":"channel-pairing","exportName":"createChannelPairingChallengeIssuer","importSpecifier":"openclaw/plugin-sdk/channel-pairing","kind":"function","recordType":"export","sourceLine":23,"sourcePath":"src/plugin-sdk/channel-pairing.ts"} +{"declaration":"export function createChannelPairingController(params: { core: PluginRuntime; channel: ChannelId; accountId: string; }): ChannelPairingController;","entrypoint":"channel-pairing","exportName":"createChannelPairingController","importSpecifier":"openclaw/plugin-sdk/channel-pairing","kind":"function","recordType":"export","sourceLine":41,"sourcePath":"src/plugin-sdk/channel-pairing.ts"} {"declaration":"export function createLoggedPairingApprovalNotifier(format: string | ((params: { cfg: OpenClawConfig; id: string; accountId?: string | undefined; runtime?: RuntimeEnv | undefined; }) => string), log?: (message: string) => void): (params: { ...; }) => Promise<...>;","entrypoint":"channel-pairing","exportName":"createLoggedPairingApprovalNotifier","importSpecifier":"openclaw/plugin-sdk/channel-pairing","kind":"function","recordType":"export","sourceLine":12,"sourcePath":"src/channels/plugins/pairing-adapters.ts"} {"declaration":"export function createPairingPrefixStripper(prefixRe: RegExp, map?: (entry: string) => string): (entry: string) => string;","entrypoint":"channel-pairing","exportName":"createPairingPrefixStripper","importSpecifier":"openclaw/plugin-sdk/channel-pairing","kind":"function","recordType":"export","sourceLine":5,"sourcePath":"src/channels/plugins/pairing-adapters.ts"} {"declaration":"export function createTextPairingAdapter(params: { idLabel: string; message: string; normalizeAllowEntry?: ((entry: string) => string) | undefined; notify: (params: { cfg: OpenClawConfig; id: string; accountId?: string | undefined; runtime?: RuntimeEnv | undefined; } & { ...; }) => void | Promise<...>; }): ChannelPairingAdapter;","entrypoint":"channel-pairing","exportName":"createTextPairingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-pairing","kind":"function","recordType":"export","sourceLine":21,"sourcePath":"src/channels/plugins/pairing-adapters.ts"} +{"declaration":"export function readChannelAllowFromStore(channel: ChannelId, env?: ProcessEnv, accountId?: string | undefined): Promise;","entrypoint":"channel-pairing","exportName":"readChannelAllowFromStore","importSpecifier":"openclaw/plugin-sdk/channel-pairing","kind":"function","recordType":"export","sourceLine":570,"sourcePath":"src/pairing/pairing-store.ts"} {"declaration":"export function readChannelAllowFromStoreSync(channel: ChannelId, env?: ProcessEnv, accountId?: string | undefined): string[];","entrypoint":"channel-pairing","exportName":"readChannelAllowFromStoreSync","importSpecifier":"openclaw/plugin-sdk/channel-pairing","kind":"function","recordType":"export","sourceLine":601,"sourcePath":"src/pairing/pairing-store.ts"} -{"declaration":"export type ChannelPairingController = ChannelPairingController;","entrypoint":"channel-pairing","exportName":"ChannelPairingController","importSpecifier":"openclaw/plugin-sdk/channel-pairing","kind":"type","recordType":"export","sourceLine":15,"sourcePath":"src/plugin-sdk/channel-pairing.ts"} +{"declaration":"export function resolveChannelAllowFromPath(channel: ChannelId, env?: ProcessEnv, accountId?: string | undefined): string;","entrypoint":"channel-pairing","exportName":"resolveChannelAllowFromPath","importSpecifier":"openclaw/plugin-sdk/channel-pairing","kind":"function","recordType":"export","sourceLine":107,"sourcePath":"src/pairing/pairing-store.ts"} +{"declaration":"export type ChannelPairingController = ChannelPairingController;","entrypoint":"channel-pairing","exportName":"ChannelPairingController","importSpecifier":"openclaw/plugin-sdk/channel-pairing","kind":"type","recordType":"export","sourceLine":16,"sourcePath":"src/plugin-sdk/channel-pairing.ts"} {"category":"channel","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/channel-reply-pipeline.ts"} -{"declaration":"export function createChannelReplyPipeline(params: { cfg: OpenClawConfig; agentId: string; channel?: string | undefined; accountId?: string | undefined; typing?: CreateTypingCallbacksParams | undefined; typingCallbacks?: TypingCallbacks | undefined; }): ChannelReplyPipeline;","entrypoint":"channel-reply-pipeline","exportName":"createChannelReplyPipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","kind":"function","recordType":"export","sourceLine":20,"sourcePath":"src/plugin-sdk/channel-reply-pipeline.ts"} -{"declaration":"export type ChannelReplyPipeline = ChannelReplyPipeline;","entrypoint":"channel-reply-pipeline","exportName":"ChannelReplyPipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","kind":"type","recordType":"export","sourceLine":16,"sourcePath":"src/plugin-sdk/channel-reply-pipeline.ts"} +{"declaration":"export function createChannelReplyPipeline(params: { cfg: OpenClawConfig; agentId: string; channel?: string | undefined; accountId?: string | undefined; typing?: CreateTypingCallbacksParams | undefined; typingCallbacks?: TypingCallbacks | undefined; }): ChannelReplyPipeline;","entrypoint":"channel-reply-pipeline","exportName":"createChannelReplyPipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","kind":"function","recordType":"export","sourceLine":25,"sourcePath":"src/plugin-sdk/channel-reply-pipeline.ts"} +{"declaration":"export function createReplyPrefixContext(params: { cfg: OpenClawConfig; agentId: string; channel?: string | undefined; accountId?: string | undefined; }): ReplyPrefixContextBundle;","entrypoint":"channel-reply-pipeline","exportName":"createReplyPrefixContext","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","kind":"function","recordType":"export","sourceLine":23,"sourcePath":"src/channels/reply-prefix.ts"} +{"declaration":"export function createReplyPrefixOptions(params: { cfg: OpenClawConfig; agentId: string; channel?: string | undefined; accountId?: string | undefined; }): ReplyPrefixOptions;","entrypoint":"channel-reply-pipeline","exportName":"createReplyPrefixOptions","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","kind":"function","recordType":"export","sourceLine":53,"sourcePath":"src/channels/reply-prefix.ts"} +{"declaration":"export function createTypingCallbacks(params: CreateTypingCallbacksParams): TypingCallbacks;","entrypoint":"channel-reply-pipeline","exportName":"createTypingCallbacks","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","kind":"function","recordType":"export","sourceLine":23,"sourcePath":"src/channels/typing.ts"} +{"declaration":"export type ChannelReplyPipeline = ChannelReplyPipeline;","entrypoint":"channel-reply-pipeline","exportName":"ChannelReplyPipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","kind":"type","recordType":"export","sourceLine":20,"sourcePath":"src/plugin-sdk/channel-reply-pipeline.ts"} {"declaration":"export type CreateTypingCallbacksParams = CreateTypingCallbacksParams;","entrypoint":"channel-reply-pipeline","exportName":"CreateTypingCallbacksParams","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","kind":"type","recordType":"export","sourceLine":11,"sourcePath":"src/channels/typing.ts"} -{"declaration":"export type ReplyPrefixContext = import(\"../auto-reply/reply/response-prefix-template.ts\").ResponsePrefixContext;","entrypoint":"channel-reply-pipeline","exportName":"ReplyPrefixContext","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","kind":"type","recordType":"export","sourceLine":12,"sourcePath":"src/plugin-sdk/channel-reply-pipeline.ts"} +{"declaration":"export type ReplyPrefixContext = import(\"../auto-reply/reply/response-prefix-template.ts\").ResponsePrefixContext;","entrypoint":"channel-reply-pipeline","exportName":"ReplyPrefixContext","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","kind":"type","recordType":"export","sourceLine":15,"sourcePath":"src/plugin-sdk/channel-reply-pipeline.ts"} {"declaration":"export type ReplyPrefixContextBundle = ReplyPrefixContextBundle;","entrypoint":"channel-reply-pipeline","exportName":"ReplyPrefixContextBundle","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","kind":"type","recordType":"export","sourceLine":11,"sourcePath":"src/channels/reply-prefix.ts"} {"declaration":"export type ReplyPrefixOptions = ReplyPrefixOptions;","entrypoint":"channel-reply-pipeline","exportName":"ReplyPrefixOptions","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","kind":"type","recordType":"export","sourceLine":18,"sourcePath":"src/channels/reply-prefix.ts"} {"declaration":"export type TypingCallbacks = TypingCallbacks;","entrypoint":"channel-reply-pipeline","exportName":"TypingCallbacks","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline","kind":"type","recordType":"export","sourceLine":4,"sourcePath":"src/channels/typing.ts"} @@ -267,83 +279,83 @@ {"declaration":"export function waitUntilAbort(signal?: AbortSignal | undefined, onAbort?: (() => void | Promise) | undefined): Promise;","entrypoint":"channel-runtime","exportName":"waitUntilAbort","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"function","recordType":"export","sourceLine":30,"sourcePath":"src/plugin-sdk/channel-lifecycle.core.ts"} {"declaration":"export const CHANNEL_MESSAGE_ACTION_NAMES: readonly [\"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\", \"set-profile\", \"download-file\", \"upload-file\"];","entrypoint":"channel-runtime","exportName":"CHANNEL_MESSAGE_ACTION_NAMES","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"const","recordType":"export","sourceLine":1,"sourcePath":"src/channels/plugins/message-action-names.ts"} {"declaration":"export const CHANNEL_MESSAGE_CAPABILITIES: readonly [\"interactive\", \"buttons\", \"cards\", \"components\", \"blocks\"];","entrypoint":"channel-runtime","exportName":"CHANNEL_MESSAGE_CAPABILITIES","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"const","recordType":"export","sourceLine":1,"sourcePath":"src/channels/plugins/message-capabilities.ts"} -{"declaration":"export type BaseProbeResult = BaseProbeResult;","entrypoint":"channel-runtime","exportName":"BaseProbeResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":630,"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":636,"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 BaseProbeResult = BaseProbeResult;","entrypoint":"channel-runtime","exportName":"BaseProbeResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":642,"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":648,"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":154,"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":511,"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":34,"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":523,"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":720,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelApprovalAdapter = ChannelApprovalAdapter;","entrypoint":"channel-runtime","exportName":"ChannelApprovalAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":714,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelApprovalCapability = ChannelApprovalCapability;","entrypoint":"channel-runtime","exportName":"ChannelApprovalCapability","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":703,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelApprovalForwardTarget = ChannelApprovalForwardTarget;","entrypoint":"channel-runtime","exportName":"ChannelApprovalForwardTarget","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":39,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelApprovalInitiatingSurfaceState = ChannelActionAvailabilityState;","entrypoint":"channel-runtime","exportName":"ChannelApprovalInitiatingSurfaceState","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":37,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelAuthAdapter = ChannelAuthAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAuthAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":409,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelCapabilities = ChannelCapabilities;","entrypoint":"channel-runtime","exportName":"ChannelCapabilities","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":233,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelCapabilitiesDiagnostics = ChannelCapabilitiesDiagnostics;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDiagnostics","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelCapabilitiesDisplayLine = ChannelCapabilitiesDisplayLine;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayLine","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":49,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelCapabilitiesDisplayTone = ChannelCapabilitiesDisplayTone;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayTone","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":47,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelCommandAdapter = ChannelCommandAdapter;","entrypoint":"channel-runtime","exportName":"ChannelCommandAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":510,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-runtime","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":785,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfigAdapter = ChannelConfigAdapter;","entrypoint":"channel-runtime","exportName":"ChannelConfigAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":98,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":776,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":781,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":797,"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":814,"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":472,"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":531,"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":529,"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":503,"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":393,"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":285,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelGroupAdapter = ChannelGroupAdapter;","entrypoint":"channel-runtime","exportName":"ChannelGroupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":130,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelGroupContext = ChannelGroupContext;","entrypoint":"channel-runtime","exportName":"ChannelGroupContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":219,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelHeartbeatAdapter = ChannelHeartbeatAdapter;","entrypoint":"channel-runtime","exportName":"ChannelHeartbeatAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":435,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelAllowlistAdapter = ChannelAllowlistAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAllowlistAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":732,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelApprovalAdapter = ChannelApprovalAdapter;","entrypoint":"channel-runtime","exportName":"ChannelApprovalAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":726,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelApprovalCapability = ChannelApprovalCapability;","entrypoint":"channel-runtime","exportName":"ChannelApprovalCapability","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":715,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelApprovalForwardTarget = ChannelApprovalForwardTarget;","entrypoint":"channel-runtime","exportName":"ChannelApprovalForwardTarget","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":41,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelApprovalInitiatingSurfaceState = ChannelActionAvailabilityState;","entrypoint":"channel-runtime","exportName":"ChannelApprovalInitiatingSurfaceState","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":39,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelAuthAdapter = ChannelAuthAdapter;","entrypoint":"channel-runtime","exportName":"ChannelAuthAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":412,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelCapabilities = ChannelCapabilities;","entrypoint":"channel-runtime","exportName":"ChannelCapabilities","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":240,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelCapabilitiesDiagnostics = ChannelCapabilitiesDiagnostics;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDiagnostics","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":56,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelCapabilitiesDisplayLine = ChannelCapabilitiesDisplayLine;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayLine","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":51,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelCapabilitiesDisplayTone = ChannelCapabilitiesDisplayTone;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayTone","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":49,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelCommandAdapter = ChannelCommandAdapter;","entrypoint":"channel-runtime","exportName":"ChannelCommandAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":513,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-runtime","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":797,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfigAdapter = ChannelConfigAdapter;","entrypoint":"channel-runtime","exportName":"ChannelConfigAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":788,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":793,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":809,"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":826,"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":475,"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":543,"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":541,"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":506,"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":395,"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":287,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelGroupAdapter = ChannelGroupAdapter;","entrypoint":"channel-runtime","exportName":"ChannelGroupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":132,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelGroupContext = ChannelGroupContext;","entrypoint":"channel-runtime","exportName":"ChannelGroupContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":226,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelHeartbeatAdapter = ChannelHeartbeatAdapter;","entrypoint":"channel-runtime","exportName":"ChannelHeartbeatAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":438,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelHeartbeatDeps = ChannelHeartbeatDeps;","entrypoint":"channel-runtime","exportName":"ChannelHeartbeatDeps","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":118,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelId = ChannelId;","entrypoint":"channel-runtime","exportName":"ChannelId","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":14,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelLifecycleAdapter = ChannelLifecycleAdapter;","entrypoint":"channel-runtime","exportName":"ChannelLifecycleAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":593,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelLoginWithQrStartResult = ChannelLoginWithQrStartResult;","entrypoint":"channel-runtime","exportName":"ChannelLoginWithQrStartResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":364,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelLoginWithQrWaitResult = ChannelLoginWithQrWaitResult;","entrypoint":"channel-runtime","exportName":"ChannelLoginWithQrWaitResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":369,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelLogoutContext = ChannelLogoutContext;","entrypoint":"channel-runtime","exportName":"ChannelLogoutContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":374,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelLogoutResult = ChannelLogoutResult;","entrypoint":"channel-runtime","exportName":"ChannelLogoutResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":358,"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":580,"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":544,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelLifecycleAdapter = ChannelLifecycleAdapter;","entrypoint":"channel-runtime","exportName":"ChannelLifecycleAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":599,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelLoginWithQrStartResult = ChannelLoginWithQrStartResult;","entrypoint":"channel-runtime","exportName":"ChannelLoginWithQrStartResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":366,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelLoginWithQrWaitResult = ChannelLoginWithQrWaitResult;","entrypoint":"channel-runtime","exportName":"ChannelLoginWithQrWaitResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":371,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelLogoutContext = ChannelLogoutContext;","entrypoint":"channel-runtime","exportName":"ChannelLogoutContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":376,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelLogoutResult = ChannelLogoutResult;","entrypoint":"channel-runtime","exportName":"ChannelLogoutResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":360,"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":219,"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":270,"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":592,"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":556,"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"} {"declaration":"export type ChannelMessageToolDiscovery = ChannelMessageToolDiscovery;","entrypoint":"channel-runtime","exportName":"ChannelMessageToolDiscovery","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":57,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelMessageToolSchemaContribution = ChannelMessageToolSchemaContribution;","entrypoint":"channel-runtime","exportName":"ChannelMessageToolSchemaContribution","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":52,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelMessagingAdapter = ChannelMessagingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelMessagingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":398,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelMeta = ChannelMeta;","entrypoint":"channel-runtime","exportName":"ChannelMeta","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":124,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelOutboundAdapter = ChannelOutboundAdapter;","entrypoint":"channel-runtime","exportName":"ChannelOutboundAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":176,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelOutboundContext = ChannelOutboundContext;","entrypoint":"channel-runtime","exportName":"ChannelOutboundContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":136,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelOutboundPayloadHint = ChannelOutboundPayloadHint;","entrypoint":"channel-runtime","exportName":"ChannelOutboundPayloadHint","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":161,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelMessagingAdapter = ChannelMessagingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelMessagingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":405,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelMeta = ChannelMeta;","entrypoint":"channel-runtime","exportName":"ChannelMeta","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":131,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelOutboundAdapter = ChannelOutboundAdapter;","entrypoint":"channel-runtime","exportName":"ChannelOutboundAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":178,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelOutboundContext = ChannelOutboundContext;","entrypoint":"channel-runtime","exportName":"ChannelOutboundContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":138,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelOutboundPayloadHint = ChannelOutboundPayloadHint;","entrypoint":"channel-runtime","exportName":"ChannelOutboundPayloadHint","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":163,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelOutboundTargetMode = ChannelOutboundTargetMode;","entrypoint":"channel-runtime","exportName":"ChannelOutboundTargetMode","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":16,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelOutboundTargetRef = ChannelOutboundTargetRef;","entrypoint":"channel-runtime","exportName":"ChannelOutboundTargetRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":165,"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":382,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelOutboundTargetRef = ChannelOutboundTargetRef;","entrypoint":"channel-runtime","exportName":"ChannelOutboundTargetRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":167,"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":384,"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":82,"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":618,"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":609,"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":483,"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":493,"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":485,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelSecurityAdapter = ChannelSecurityAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSecurityAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":884,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelSecurityContext = ChannelSecurityContext;","entrypoint":"channel-runtime","exportName":"ChannelSecurityContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":257,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelSecurityDmPolicy = ChannelSecurityDmPolicy;","entrypoint":"channel-runtime","exportName":"ChannelSecurityDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":248,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":63,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelPollContext = ChannelPollContext;","entrypoint":"channel-runtime","exportName":"ChannelPollContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":630,"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":621,"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":486,"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":496,"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":488,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelSecurityAdapter = ChannelSecurityAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSecurityAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":899,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelSecurityContext = ChannelSecurityContext;","entrypoint":"channel-runtime","exportName":"ChannelSecurityContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":264,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelSecurityDmPolicy = ChannelSecurityDmPolicy;","entrypoint":"channel-runtime","exportName":"ChannelSecurityDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":255,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":65,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelSetupInput = ChannelSetupInput;","entrypoint":"channel-runtime","exportName":"ChannelSetupInput","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":64,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelStatusAdapter = ChannelStatusAdapter;","entrypoint":"channel-runtime","exportName":"ChannelStatusAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":230,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelStatusAdapter = ChannelStatusAdapter;","entrypoint":"channel-runtime","exportName":"ChannelStatusAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":232,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type ChannelStatusIssue = ChannelStatusIssue;","entrypoint":"channel-runtime","exportName":"ChannelStatusIssue","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":102,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelStreamingAdapter = ChannelStreamingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelStreamingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":282,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelStructuredComponents = ChannelStructuredComponents;","entrypoint":"channel-runtime","exportName":"ChannelStructuredComponents","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":291,"sourcePath":"src/channels/plugins/types.core.ts"} -{"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":573,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelStreamingAdapter = ChannelStreamingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelStreamingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":289,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelStructuredComponents = ChannelStructuredComponents;","entrypoint":"channel-runtime","exportName":"ChannelStructuredComponents","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":298,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelThreadingAdapter = ChannelThreadingAdapter;","entrypoint":"channel-runtime","exportName":"ChannelThreadingAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":332,"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":375,"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":389,"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":585,"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"} @@ -362,11 +374,11 @@ {"declaration":"export function createOptionalChannelSetupWizard(params: OptionalChannelSetupParams): ChannelSetupWizard;","entrypoint":"channel-setup","exportName":"createOptionalChannelSetupWizard","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"function","recordType":"export","sourceLine":35,"sourcePath":"src/plugin-sdk/optional-channel-setup.ts"} {"declaration":"export function createTopLevelChannelDmPolicy(params: { label: string; channel: string; policyKey: string; allowFromKey: string; getCurrent: (cfg: OpenClawConfig) => DmPolicy; promptAllowFrom?: ((params: { cfg: OpenClawConfig; prompter: WizardPrompter; accountId?: string | undefined; }) => Promise<...>) | undefined; getAllowFrom?: ((cfg: OpenClawConfig) => (string | number)[] | undefined) | undefined; }): ChannelSetupDmPolicy;","entrypoint":"channel-setup","exportName":"createTopLevelChannelDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"function","recordType":"export","sourceLine":413,"sourcePath":"src/channels/plugins/setup-wizard-helpers.ts"} {"declaration":"export function formatDocsLink(path: string, label?: string | undefined, opts?: { fallback?: string | undefined; force?: boolean | undefined; } | undefined): string;","entrypoint":"channel-setup","exportName":"formatDocsLink","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"function","recordType":"export","sourceLine":9,"sourcePath":"src/terminal/links.ts"} -{"declaration":"export function setSetupChannelEnabled(cfg: OpenClawConfig, channel: string, enabled: boolean): OpenClawConfig;","entrypoint":"channel-setup","exportName":"setSetupChannelEnabled","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"function","recordType":"export","sourceLine":882,"sourcePath":"src/channels/plugins/setup-wizard-helpers.ts"} +{"declaration":"export function setSetupChannelEnabled(cfg: OpenClawConfig, channel: string, enabled: boolean): OpenClawConfig;","entrypoint":"channel-setup","exportName":"setSetupChannelEnabled","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"function","recordType":"export","sourceLine":874,"sourcePath":"src/channels/plugins/setup-wizard-helpers.ts"} {"declaration":"export function splitSetupEntries(raw: string): string[];","entrypoint":"channel-setup","exportName":"splitSetupEntries","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"function","recordType":"export","sourceLine":80,"sourcePath":"src/channels/plugins/setup-wizard-helpers.ts"} {"declaration":"export const DEFAULT_ACCOUNT_ID: \"default\";","entrypoint":"channel-setup","exportName":"DEFAULT_ACCOUNT_ID","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"const","recordType":"export","sourceLine":3,"sourcePath":"src/routing/account-id.ts"} -{"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"channel-setup","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":63,"sourcePath":"src/channels/plugins/types.adapters.ts"} -{"declaration":"export type ChannelSetupDmPolicy = ChannelSetupDmPolicy;","entrypoint":"channel-setup","exportName":"ChannelSetupDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":93,"sourcePath":"src/channels/plugins/setup-wizard-types.ts"} +{"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"channel-setup","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":65,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelSetupDmPolicy = ChannelSetupDmPolicy;","entrypoint":"channel-setup","exportName":"ChannelSetupDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":90,"sourcePath":"src/channels/plugins/setup-wizard-types.ts"} {"declaration":"export type ChannelSetupInput = ChannelSetupInput;","entrypoint":"channel-setup","exportName":"ChannelSetupInput","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":64,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelSetupWizard = ChannelSetupWizard;","entrypoint":"channel-setup","exportName":"ChannelSetupWizard","importSpecifier":"openclaw/plugin-sdk/channel-setup","kind":"type","recordType":"export","sourceLine":253,"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"} @@ -441,38 +453,50 @@ {"declaration":"export type SkillCommandSpec = SkillCommandSpec;","entrypoint":"command-auth","exportName":"SkillCommandSpec","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"type","recordType":"export","sourceLine":51,"sourcePath":"src/agents/skills/types.ts"} {"declaration":"export type StoredModelOverride = StoredModelOverride;","entrypoint":"command-auth","exportName":"StoredModelOverride","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"type","recordType":"export","sourceLine":124,"sourcePath":"src/auto-reply/reply/model-selection.ts"} {"category":"core","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/core.ts"} -{"declaration":"export function applyAccountNameToChannelSection(params: { cfg: OpenClawConfig; channelKey: string; accountId: string; name?: string | undefined; alwaysUseAccounts?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"applyAccountNameToChannelSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":34,"sourcePath":"src/channels/plugins/setup-helpers.ts"} +{"declaration":"export function applyAccountNameToChannelSection(params: { cfg: OpenClawConfig; channelKey: string; accountId: string; name?: string | undefined; alwaysUseAccounts?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"applyAccountNameToChannelSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":35,"sourcePath":"src/channels/plugins/setup-helpers.ts"} {"declaration":"export function buildAgentSessionKey(params: { agentId: string; channel: string; accountId?: string | null | undefined; peer?: RoutePeer | null | undefined; dmScope?: \"main\" | \"per-peer\" | \"per-channel-peer\" | \"per-account-channel-peer\" | undefined; identityLinks?: Record<...> | undefined; }): string;","entrypoint":"core","exportName":"buildAgentSessionKey","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":92,"sourcePath":"src/routing/resolve-route.ts"} {"declaration":"export function buildChannelConfigSchema(schema: ZodType>, options?: BuildChannelConfigSchemaOptions | undefined): ChannelConfigSchema;","entrypoint":"core","exportName":"buildChannelConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":76,"sourcePath":"src/channels/plugins/config-schema.ts"} -{"declaration":"export function buildChannelOutboundSessionRoute(params: { cfg: OpenClawConfig; agentId: string; channel: string; accountId?: string | null | undefined; peer: { kind: \"direct\" | \"group\" | \"channel\"; id: string; }; chatType: \"direct\" | \"group\" | \"channel\"; from: string; to: string; threadId?: string | ... 1 more ... | undefined; }): ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"buildChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":193,"sourcePath":"src/plugin-sdk/core.ts"} +{"declaration":"export function buildChannelOutboundSessionRoute(params: { cfg: OpenClawConfig; agentId: string; channel: string; accountId?: string | null | undefined; peer: { kind: \"direct\" | \"group\" | \"channel\"; id: string; }; chatType: \"direct\" | \"group\" | \"channel\"; from: string; to: string; threadId?: string | ... 1 more ... | undefined; }): ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"buildChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":226,"sourcePath":"src/plugin-sdk/core.ts"} {"declaration":"export function buildPluginConfigSchema(schema: ZodType>, options?: BuildPluginConfigSchemaOptions | undefined): OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"buildPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":78,"sourcePath":"src/plugins/config-schema.ts"} {"declaration":"export function channelTargetSchema(options?: { description?: string | undefined; } | undefined): TString;","entrypoint":"core","exportName":"channelTargetSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":38,"sourcePath":"src/agents/schema/typebox.ts"} {"declaration":"export function channelTargetsSchema(options?: { description?: string | undefined; } | undefined): TArray;","entrypoint":"core","exportName":"channelTargetsSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":44,"sourcePath":"src/agents/schema/typebox.ts"} {"declaration":"export function clearAccountEntryFields(params: { accounts?: Record | undefined; accountId: string; fields: string[]; isValueSet?: ((value: unknown) => boolean) | undefined; markClearedOnFieldPresence?: boolean | undefined; }): { ...; };","entrypoint":"core","exportName":"clearAccountEntryFields","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":122,"sourcePath":"src/channels/plugins/config-helpers.ts"} -{"declaration":"export function createChannelPluginBase(params: CreateChannelPluginBaseOptions): CreatedChannelPluginBase;","entrypoint":"core","exportName":"createChannelPluginBase","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":558,"sourcePath":"src/plugin-sdk/core.ts"} -{"declaration":"export function createChatChannelPlugin(params: { base: ChatChannelPluginBase; security?: ChannelSecurityAdapter | ChatChannelSecurityOptions<...> | undefined; pairing?: ChannelPairingAdapter | ... 1 more ... | undefined; threading?: ChannelThreadingAdapter | ... 1 more ... | undefined; outbound?: ChannelOutboundAdapter | ... 1 more ... | undefined; }): ChannelPlugin<...>;","entrypoint":"core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":531,"sourcePath":"src/plugin-sdk/core.ts"} +{"declaration":"export function createActionGate>(actions: T | undefined): ActionGate;","entrypoint":"core","exportName":"createActionGate","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":46,"sourcePath":"src/agents/tools/common.ts"} +{"declaration":"export function createChannelPluginBase(params: CreateChannelPluginBaseOptions): CreatedChannelPluginBase;","entrypoint":"core","exportName":"createChannelPluginBase","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":591,"sourcePath":"src/plugin-sdk/core.ts"} +{"declaration":"export function createChatChannelPlugin(params: { base: ChatChannelPluginBase; security?: ChannelSecurityAdapter | ChatChannelSecurityOptions<...> | undefined; pairing?: ChannelPairingAdapter | ... 1 more ... | undefined; threading?: ChannelThreadingAdapter | ... 1 more ... | undefined; outbound?: ChannelOutboundAdapter | ... 1 more ... | undefined; }): ChannelPlugin<...>;","entrypoint":"core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":564,"sourcePath":"src/plugin-sdk/core.ts"} {"declaration":"export function createDedupeCache(options: DedupeCacheOptions): DedupeCache;","entrypoint":"core","exportName":"createDedupeCache","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":17,"sourcePath":"src/infra/dedupe.ts"} -{"declaration":"export function createSubsystemLogger(subsystem: string): SubsystemLogger;","entrypoint":"core","exportName":"createSubsystemLogger","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":308,"sourcePath":"src/logging/subsystem.ts"} -{"declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":291,"sourcePath":"src/plugin-sdk/core.ts"} +{"declaration":"export function createSubsystemLogger(subsystem: string): SubsystemLogger;","entrypoint":"core","exportName":"createSubsystemLogger","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":302,"sourcePath":"src/logging/subsystem.ts"} +{"declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":324,"sourcePath":"src/plugin-sdk/core.ts"} {"declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"core","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":157,"sourcePath":"src/plugin-sdk/plugin-entry.ts"} -{"declaration":"export function defineSetupPluginEntry(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":334,"sourcePath":"src/plugin-sdk/core.ts"} +{"declaration":"export function defineSetupPluginEntry(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":367,"sourcePath":"src/plugin-sdk/core.ts"} {"declaration":"export function delegateCompactionToRuntime(params: { sessionId: string; sessionKey?: string | undefined; sessionFile: string; tokenBudget?: number | undefined; force?: boolean | undefined; currentTokenCount?: number | undefined; compactionTarget?: \"budget\" | ... 1 more ... | undefined; customInstructions?: string | undefined; runtimeContext?: ContextEngineRuntimeContext | undefined; }): Promise<...>;","entrypoint":"core","exportName":"delegateCompactionToRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/context-engine/delegate.ts"} {"declaration":"export function deleteAccountFromConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; accountId: string; clearBaseFields?: string[] | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"deleteAccountFromConfigSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":60,"sourcePath":"src/channels/plugins/config-helpers.ts"} {"declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":108,"sourcePath":"src/plugins/config-schema.ts"} {"declaration":"export function enqueueKeyedTask(params: { tails: Map>; key: string; task: () => Promise; hooks?: KeyedAsyncQueueHooks | undefined; }): Promise<...>;","entrypoint":"core","exportName":"enqueueKeyedTask","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":7,"sourcePath":"src/plugin-sdk/keyed-async-queue.ts"} +{"declaration":"export function ensureConfiguredAcpBindingReady(params: { cfg: OpenClawConfig; configuredBinding: ResolvedConfiguredAcpBinding | null; }): Promise<{ ok: true; } | { ok: false; error: string; }>;","entrypoint":"core","exportName":"ensureConfiguredAcpBindingReady","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":110,"sourcePath":"src/acp/persistent-bindings.lifecycle.ts"} {"declaration":"export function formatPairingApproveHint(channelId: string): string;","entrypoint":"core","exportName":"formatPairingApproveHint","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":17,"sourcePath":"src/channels/plugins/helpers.ts"} +{"declaration":"export function formatZonedTimestamp(date: Date, options?: FormatZonedTimestampOptions | undefined): string | undefined;","entrypoint":"core","exportName":"formatZonedTimestamp","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":57,"sourcePath":"src/infra/format-time/format-datetime.ts"} {"declaration":"export function generateSecureToken(bytes?: number): string;","entrypoint":"core","exportName":"generateSecureToken","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":7,"sourcePath":"src/infra/secure-random.ts"} {"declaration":"export function generateSecureUuid(): string;","entrypoint":"core","exportName":"generateSecureUuid","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":3,"sourcePath":"src/infra/secure-random.ts"} {"declaration":"export function getChatChannelMeta(id: \"telegram\" | \"whatsapp\" | \"discord\" | \"irc\" | \"googlechat\" | \"slack\" | \"signal\" | \"imessage\" | \"line\"): ChannelMeta;","entrypoint":"core","exportName":"getChatChannelMeta","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":106,"sourcePath":"src/channels/chat-meta.ts"} {"declaration":"export function isSecretRef(value: unknown): value is SecretRef;","entrypoint":"core","exportName":"isSecretRef","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":34,"sourcePath":"src/config/types.secrets.ts"} +{"declaration":"export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[] | undefined): boolean;","entrypoint":"core","exportName":"isTrustedProxyAddress","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":136,"sourcePath":"src/gateway/net.ts"} +{"declaration":"export function jsonResult(payload: unknown): AgentToolResult;","entrypoint":"core","exportName":"jsonResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":256,"sourcePath":"src/agents/tools/common.ts"} {"declaration":"export function loadSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): SecretFileReadResult;","entrypoint":"core","exportName":"loadSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":29,"sourcePath":"src/infra/secret-file.ts"} -{"declaration":"export function migrateBaseNameToDefaultAccount(params: { cfg: OpenClawConfig; channelKey: string; alwaysUseAccounts?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"migrateBaseNameToDefaultAccount","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":93,"sourcePath":"src/channels/plugins/setup-helpers.ts"} +{"declaration":"export function migrateBaseNameToDefaultAccount(params: { cfg: OpenClawConfig; channelKey: string; alwaysUseAccounts?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"migrateBaseNameToDefaultAccount","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":94,"sourcePath":"src/channels/plugins/setup-helpers.ts"} {"declaration":"export function normalizeAccountId(value: string | null | undefined): string;","entrypoint":"core","exportName":"normalizeAccountId","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":34,"sourcePath":"src/routing/account-id.ts"} {"declaration":"export function normalizeAtHashSlug(raw?: string | null | undefined): string;","entrypoint":"core","exportName":"normalizeAtHashSlug","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":19,"sourcePath":"src/shared/string-normalization.ts"} {"declaration":"export function normalizeHyphenSlug(raw?: string | null | undefined): string;","entrypoint":"core","exportName":"normalizeHyphenSlug","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":9,"sourcePath":"src/shared/string-normalization.ts"} {"declaration":"export function optionalStringEnum(values: T, options?: StringEnumOptions): TOptional>;","entrypoint":"core","exportName":"optionalStringEnum","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":31,"sourcePath":"src/agents/schema/typebox.ts"} {"declaration":"export function parseOptionalDelimitedEntries(value?: string | undefined): string[] | undefined;","entrypoint":"core","exportName":"parseOptionalDelimitedEntries","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":23,"sourcePath":"src/channels/plugins/helpers.ts"} +{"declaration":"export function parseStrictPositiveInteger(value: unknown): number | undefined;","entrypoint":"core","exportName":"parseStrictPositiveInteger","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":34,"sourcePath":"src/infra/parse-finite-number.ts"} +{"declaration":"export function readNumberParam(params: Record, key: string, options?: { required?: boolean | undefined; label?: string | undefined; integer?: boolean | undefined; strict?: boolean | undefined; }): number | undefined;","entrypoint":"core","exportName":"readNumberParam","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":117,"sourcePath":"src/agents/tools/common.ts"} +{"declaration":"export function readReactionParams(params: Record, options: { emojiKey?: string | undefined; removeKey?: string | undefined; removeErrorMessage: string; }): ReactionParams;","entrypoint":"core","exportName":"readReactionParams","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":197,"sourcePath":"src/agents/tools/common.ts"} {"declaration":"export function readSecretFileSync(filePath: string, label: string, options?: SecretFileReadOptions): string;","entrypoint":"core","exportName":"readSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":118,"sourcePath":"src/infra/secret-file.ts"} +{"declaration":"export function readStringArrayParam(params: Record, key: string, options: StringParamOptions & { required: true; }): string[];\nexport function readStringArrayParam(params: Record, key: string, options?: StringParamOptions | undefined): string[] | undefined;","entrypoint":"core","exportName":"readStringArrayParam","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":145,"sourcePath":"src/agents/tools/common.ts"} +{"declaration":"export function readStringParam(params: Record, key: string, options: StringParamOptions & { required: true; }): string;\nexport function readStringParam(params: Record, key: string, options?: StringParamOptions | undefined): string | undefined;","entrypoint":"core","exportName":"readStringParam","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":62,"sourcePath":"src/agents/tools/common.ts"} +{"declaration":"export function resolveClientIp(params: { remoteAddr?: string | undefined; forwardedFor?: string | undefined; realIp?: string | undefined; trustedProxies?: string[] | undefined; allowRealIpFallback?: boolean | undefined; }): string | undefined;","entrypoint":"core","exportName":"resolveClientIp","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":151,"sourcePath":"src/gateway/net.ts"} +{"declaration":"export function resolveConfiguredAcpBindingRecord(params: { cfg: OpenClawConfig; channel: string; accountId: string; conversationId: string; parentConversationId?: string | undefined; }): ResolvedConfiguredAcpBinding | null;","entrypoint":"core","exportName":"resolveConfiguredAcpBindingRecord","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":15,"sourcePath":"src/acp/persistent-bindings.resolve.ts"} {"declaration":"export function resolveGatewayBindUrl(params: { bind?: string | undefined; customBindHost?: string | undefined; scheme: \"ws\" | \"wss\"; port: number; pickTailnetHost: () => string | null; pickLanHost: () => string | null; }): GatewayBindUrlResult;","entrypoint":"core","exportName":"resolveGatewayBindUrl","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":11,"sourcePath":"src/shared/gateway-bind-url.ts"} {"declaration":"export function resolveGatewayPort(cfg?: OpenClawConfig | undefined, env?: ProcessEnv): number;","entrypoint":"core","exportName":"resolveGatewayPort","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":285,"sourcePath":"src/config/paths.ts"} {"declaration":"export function resolveGlobalDedupeCache(key: symbol, options: DedupeCacheOptions): DedupeCache;","entrypoint":"core","exportName":"resolveGlobalDedupeCache","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":89,"sourcePath":"src/infra/dedupe.ts"} @@ -480,21 +504,33 @@ {"declaration":"export function resolveThreadSessionKeys(params: { baseSessionKey: string; threadId?: string | null | undefined; parentSessionKey?: string | undefined; useSuffix?: boolean | undefined; normalizeThreadId?: ((threadId: string) => string) | undefined; }): { ...; };","entrypoint":"core","exportName":"resolveThreadSessionKeys","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":234,"sourcePath":"src/routing/session-key.ts"} {"declaration":"export function setAccountEnabledInConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; accountId: string; enabled: boolean; allowTopLevel?: boolean | undefined; }): OpenClawConfig;","entrypoint":"core","exportName":"setAccountEnabledInConfigSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/channels/plugins/config-helpers.ts"} {"declaration":"export function stringEnum(values: T, options?: StringEnumOptions): TUnsafe;","entrypoint":"core","exportName":"stringEnum","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":15,"sourcePath":"src/agents/schema/typebox.ts"} -{"declaration":"export function stripChannelTargetPrefix(raw: string, ...providers: string[]): string;","entrypoint":"core","exportName":"stripChannelTargetPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":173,"sourcePath":"src/plugin-sdk/core.ts"} -{"declaration":"export function stripTargetKindPrefix(raw: string): string;","entrypoint":"core","exportName":"stripTargetKindPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":185,"sourcePath":"src/plugin-sdk/core.ts"} +{"declaration":"export function stripChannelTargetPrefix(raw: string, ...providers: string[]): string;","entrypoint":"core","exportName":"stripChannelTargetPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":206,"sourcePath":"src/plugin-sdk/core.ts"} +{"declaration":"export function stripTargetKindPrefix(raw: string): string;","entrypoint":"core","exportName":"stripTargetKindPrefix","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":218,"sourcePath":"src/plugin-sdk/core.ts"} {"declaration":"export function tryReadSecretFileSync(filePath: string | undefined, label: string, options?: SecretFileReadOptions): string | undefined;","entrypoint":"core","exportName":"tryReadSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export","sourceLine":130,"sourcePath":"src/infra/secret-file.ts"} {"declaration":"export const DEFAULT_ACCOUNT_ID: \"default\";","entrypoint":"core","exportName":"DEFAULT_ACCOUNT_ID","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export","sourceLine":3,"sourcePath":"src/routing/account-id.ts"} {"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 AllowlistMatch = AllowlistMatch;","entrypoint":"core","exportName":"AllowlistMatch","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/channels/allowlist-match.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 BaseProbeResult = BaseProbeResult;","entrypoint":"core","exportName":"BaseProbeResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":642,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"core","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":154,"sourcePath":"src/channels/plugins/types.core.ts"} {"declaration":"export type ChannelConfigUiHint = ChannelConfigUiHint;","entrypoint":"core","exportName":"ChannelConfigUiHint","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":41,"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":544,"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":168,"sourcePath":"src/plugin-sdk/core.ts"} +{"declaration":"export type ChannelDirectoryEntry = ChannelDirectoryEntry;","entrypoint":"core","exportName":"ChannelDirectoryEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":543,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelGroupContext = ChannelGroupContext;","entrypoint":"core","exportName":"ChannelGroupContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":226,"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":556,"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":"core","exportName":"ChannelMessageActionName","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":6,"sourcePath":"src/channels/plugins/types.ts"} +{"declaration":"export type ChannelMessagingAdapter = ChannelMessagingAdapter;","entrypoint":"core","exportName":"ChannelMessagingAdapter","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":405,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelMeta = ChannelMeta;","entrypoint":"core","exportName":"ChannelMeta","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":131,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelOutboundAdapter = ChannelOutboundAdapter;","entrypoint":"core","exportName":"ChannelOutboundAdapter","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":178,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelOutboundSessionRoute = ChannelOutboundSessionRoute;","entrypoint":"core","exportName":"ChannelOutboundSessionRoute","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":319,"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":201,"sourcePath":"src/plugin-sdk/core.ts"} {"declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"core","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":82,"sourcePath":"src/channels/plugins/types.plugin.ts"} +{"declaration":"export type ChannelSetupInput = ChannelSetupInput;","entrypoint":"core","exportName":"ChannelSetupInput","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":64,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChatType = ChatType;","entrypoint":"core","exportName":"ChatType","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/channels/chat-type.ts"} {"declaration":"export type GatewayBindUrlResult = GatewayBindUrlResult;","entrypoint":"core","exportName":"GatewayBindUrlResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/shared/gateway-bind-url.ts"} {"declaration":"export type GatewayRequestHandlerOptions = GatewayRequestHandlerOptions;","entrypoint":"core","exportName":"GatewayRequestHandlerOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":116,"sourcePath":"src/gateway/server-methods/types.ts"} +{"declaration":"export type HistoryEntry = HistoryEntry;","entrypoint":"core","exportName":"HistoryEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":30,"sourcePath":"src/auto-reply/reply/history.ts"} {"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"core","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1527,"sourcePath":"src/plugins/types.ts"} +{"declaration":"export type NormalizedLocation = NormalizedLocation;","entrypoint":"core","exportName":"NormalizedLocation","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":3,"sourcePath":"src/channels/location.ts"} {"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"core","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"} {"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1794,"sourcePath":"src/plugins/types.ts"} {"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1650,"sourcePath":"src/plugins/types.ts"} @@ -504,9 +540,11 @@ {"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"core","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1735,"sourcePath":"src/plugins/types.ts"} {"declaration":"export type OpenClawPluginToolContext = OpenClawPluginToolContext;","entrypoint":"core","exportName":"OpenClawPluginToolContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":115,"sourcePath":"src/plugins/types.ts"} {"declaration":"export type OpenClawPluginToolFactory = OpenClawPluginToolFactory;","entrypoint":"core","exportName":"OpenClawPluginToolFactory","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":140,"sourcePath":"src/plugins/types.ts"} +{"declaration":"export type OutboundIdentity = OutboundIdentity;","entrypoint":"core","exportName":"OutboundIdentity","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":5,"sourcePath":"src/infra/outbound/identity.ts"} {"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"core","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1542,"sourcePath":"src/plugins/types.ts"} {"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"core","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":71,"sourcePath":"src/plugins/types.ts"} {"declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"core","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/types.ts"} +{"declaration":"export type PollInput = PollInput;","entrypoint":"core","exportName":"PollInput","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/polls.ts"} {"declaration":"export type ProviderAugmentModelCatalogContext = ProviderAugmentModelCatalogContext;","entrypoint":"core","exportName":"ProviderAugmentModelCatalogContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":827,"sourcePath":"src/plugins/types.ts"} {"declaration":"export type ProviderAuthContext = ProviderAuthContext;","entrypoint":"core","exportName":"ProviderAuthContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":175,"sourcePath":"src/plugins/types.ts"} {"declaration":"export type ProviderAuthDoctorHintContext = ProviderAuthDoctorHintContext;","entrypoint":"core","exportName":"ProviderAuthDoctorHintContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":514,"sourcePath":"src/plugins/types.ts"} @@ -546,8 +584,10 @@ {"declaration":"export type ProviderUsageSnapshot = ProviderUsageSnapshot;","entrypoint":"core","exportName":"ProviderUsageSnapshot","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":7,"sourcePath":"src/infra/provider-usage.types.ts"} {"declaration":"export type ProviderValidateReplayTurnsContext = ProviderValidateReplayTurnsContext;","entrypoint":"core","exportName":"ProviderValidateReplayTurnsContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":624,"sourcePath":"src/plugins/types.ts"} {"declaration":"export type ProviderWrapStreamFnContext = ProviderWrapStreamFnContext;","entrypoint":"core","exportName":"ProviderWrapStreamFnContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":677,"sourcePath":"src/plugins/types.ts"} +{"declaration":"export type ReplyPayload = ReplyPayload;","entrypoint":"core","exportName":"ReplyPayload","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":85,"sourcePath":"src/auto-reply/types.ts"} {"declaration":"export type RoutePeer = RoutePeer;","entrypoint":"core","exportName":"RoutePeer","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":21,"sourcePath":"src/routing/resolve-route.ts"} {"declaration":"export type RoutePeerKind = ChatType;","entrypoint":"core","exportName":"RoutePeerKind","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":19,"sourcePath":"src/routing/resolve-route.ts"} +{"declaration":"export type RuntimeLogger = RuntimeLogger;","entrypoint":"core","exportName":"RuntimeLogger","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":7,"sourcePath":"src/plugins/runtime/types-core.ts"} {"declaration":"export type SecretFileReadOptions = SecretFileReadOptions;","entrypoint":"core","exportName":"SecretFileReadOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":7,"sourcePath":"src/infra/secret-file.ts"} {"declaration":"export type SecretFileReadResult = SecretFileReadResult;","entrypoint":"core","exportName":"SecretFileReadResult","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":12,"sourcePath":"src/infra/secret-file.ts"} {"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"core","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1502,"sourcePath":"src/plugins/types.ts"} @@ -555,6 +595,7 @@ {"declaration":"export type TailscaleStatusCommandRunner = TailscaleStatusCommandRunner;","entrypoint":"core","exportName":"TailscaleStatusCommandRunner","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":9,"sourcePath":"src/shared/tailscale-status.ts"} {"declaration":"export type UsageProviderId = UsageProviderId;","entrypoint":"core","exportName":"UsageProviderId","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":20,"sourcePath":"src/infra/provider-usage.types.ts"} {"declaration":"export type UsageWindow = UsageWindow;","entrypoint":"core","exportName":"UsageWindow","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/infra/provider-usage.types.ts"} +{"declaration":"export type WizardPrompter = WizardPrompter;","entrypoint":"core","exportName":"WizardPrompter","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":37,"sourcePath":"src/wizard/prompts.ts"} {"declaration":"export class KeyedAsyncQueue","entrypoint":"core","exportName":"KeyedAsyncQueue","importSpecifier":"openclaw/plugin-sdk/core","kind":"class","recordType":"export","sourceLine":34,"sourcePath":"src/plugin-sdk/keyed-async-queue.ts"} {"category":"core","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/plugin-entry.ts"} {"declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"plugin-entry","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export","sourceLine":157,"sourcePath":"src/plugin-sdk/plugin-entry.ts"} @@ -688,13 +729,10 @@ {"declaration":"export function createEmptyPluginRegistry(): PluginRegistry;","entrypoint":"testing","exportName":"createEmptyPluginRegistry","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":3,"sourcePath":"src/plugins/registry-empty.ts"} {"declaration":"export function createRequestCaptureJsonFetch(responseBody: unknown): { fetchFn: ((input: RequestInfo | URL, init?: RequestInit | undefined) => Promise) & FetchWithPreconnect; getRequest: () => { ...; }; };","entrypoint":"testing","exportName":"createRequestCaptureJsonFetch","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":61,"sourcePath":"src/media-understanding/audio.test-helpers.ts"} {"declaration":"export function createSandboxTestContext(params?: { overrides?: Partial | undefined; dockerOverrides?: Partial | undefined; } | undefined): SandboxContext;","entrypoint":"testing","exportName":"createSandboxTestContext","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":3,"sourcePath":"src/agents/sandbox/test-fixtures.ts"} -{"declaration":"export function createSlackOutboundPayloadHarness(params: { payload: ReplyPayload; sendResults?: { messageId: string; }[] | undefined; }): SlackOutboundPayloadHarness;","entrypoint":"testing","exportName":"createSlackOutboundPayloadHarness","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":26,"sourcePath":"src/channels/plugins/contracts/slack-outbound-harness.ts"} {"declaration":"export function createTempHomeEnv(prefix: string): Promise;","entrypoint":"testing","exportName":"createTempHomeEnv","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":20,"sourcePath":"src/test-utils/temp-home.ts"} -{"declaration":"export function createWhatsAppPollFixture(): { cfg: OpenClawConfig; poll: { question: string; options: string[]; maxSelections: number; }; to: string; accountId: string; };","entrypoint":"testing","exportName":"createWhatsAppPollFixture","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":4,"sourcePath":"src/test-helpers/whatsapp-outbound.ts"} {"declaration":"export function createWindowsCmdShimFixture(params: { shimPath: string; scriptPath: string; shimLine: string; }): Promise;","entrypoint":"testing","exportName":"createWindowsCmdShimFixture","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":4,"sourcePath":"src/test-helpers/windows-cmd-shim.ts"} {"declaration":"export function expectChannelInboundContextContract(ctx: MsgContext): void;","entrypoint":"testing","exportName":"expectChannelInboundContextContract","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":22,"sourcePath":"src/channels/plugins/contracts/test-helpers.ts"} {"declaration":"export function expectGeneratedTokenPersistedToGatewayAuth(params: { generatedToken?: string | undefined; authToken?: string | undefined; persistedConfig?: OpenClawConfig | undefined; }): void;","entrypoint":"testing","exportName":"expectGeneratedTokenPersistedToGatewayAuth","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":4,"sourcePath":"src/test-utils/auth-token-assertions.ts"} -{"declaration":"export function expectWhatsAppPollSent(sendPollWhatsApp: MockInstance, params: { cfg: OpenClawConfig; poll: { question: string; options: string[]; maxSelections: number; }; to?: string | undefined; accountId?: string | undefined; }): void;","entrypoint":"testing","exportName":"expectWhatsAppPollSent","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":19,"sourcePath":"src/test-helpers/whatsapp-outbound.ts"} {"declaration":"export function firstWrittenJsonArg(writeJson: MockCallsWithFirstArg): T | null;","entrypoint":"testing","exportName":"firstWrittenJsonArg","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":92,"sourcePath":"src/cli/test-runtime-capture.ts"} {"declaration":"export function getActivePluginRegistry(): PluginRegistry | null;","entrypoint":"testing","exportName":"getActivePluginRegistry","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":95,"sourcePath":"src/plugins/runtime.ts"} {"declaration":"export function hasBalancedFences(chunk: string): boolean;","entrypoint":"testing","exportName":"hasBalancedFences","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":5,"sourcePath":"src/test-utils/chunk-test-helpers.ts"} @@ -717,7 +755,7 @@ {"declaration":"export function runAcpRuntimeAdapterContract(params: AcpRuntimeAdapterContractParams): Promise;","entrypoint":"testing","exportName":"runAcpRuntimeAdapterContract","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":19,"sourcePath":"src/acp/runtime/adapter-contract.testkit.ts"} {"declaration":"export function sanitizeTerminalText(input: string): string;","entrypoint":"testing","exportName":"sanitizeTerminalText","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":6,"sourcePath":"src/terminal/safe-text.ts"} {"declaration":"export function setActivePluginRegistry(registry: PluginRegistry, cacheKey?: string | undefined, runtimeSubagentMode?: \"default\" | \"explicit\" | \"gateway-bindable\"): void;","entrypoint":"testing","exportName":"setActivePluginRegistry","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":82,"sourcePath":"src/plugins/runtime.ts"} -{"declaration":"export function setDefaultChannelPluginRegistryForTests(): void;","entrypoint":"testing","exportName":"setDefaultChannelPluginRegistryForTests","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":126,"sourcePath":"src/commands/channel-test-registry.ts"} +{"declaration":"export function setDefaultChannelPluginRegistryForTests(): void;","entrypoint":"testing","exportName":"setDefaultChannelPluginRegistryForTests","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":41,"sourcePath":"src/commands/channel-test-registry.ts"} {"declaration":"export function shouldAckReaction(params: AckReactionGateParams): boolean;","entrypoint":"testing","exportName":"shouldAckReaction","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/channels/ack-reactions.ts"} {"declaration":"export function spyRuntimeErrors(runtime: Pick): Mock<(...args: unknown[]) => void>;","entrypoint":"testing","exportName":"spyRuntimeErrors","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":84,"sourcePath":"src/cli/test-runtime-capture.ts"} {"declaration":"export function spyRuntimeJson(runtime: Pick): Mock<(value: unknown, space?: number | undefined) => void>;","entrypoint":"testing","exportName":"spyRuntimeJson","importSpecifier":"openclaw/plugin-sdk/testing","kind":"function","recordType":"export","sourceLine":88,"sourcePath":"src/cli/test-runtime-capture.ts"} @@ -730,8 +768,8 @@ {"declaration":"export const __testing: { resetAcpSessionManagerForTests(): void; setAcpSessionManagerForTests(manager: unknown): void; };","entrypoint":"testing","exportName":"__testing","importSpecifier":"openclaw/plugin-sdk/testing","kind":"const","recordType":"export","sourceLine":25,"sourcePath":"src/acp/control-plane/manager.ts"} {"declaration":"export const __testing: { resetAcpSessionManagerForTests(): void; setAcpSessionManagerForTests(manager: unknown): void; };","entrypoint":"testing","exportName":"acpManagerTesting","importSpecifier":"openclaw/plugin-sdk/testing","kind":"const","recordType":"export","sourceLine":25,"sourcePath":"src/acp/control-plane/manager.ts"} {"declaration":"export const handleAcpCommand: CommandHandler;","entrypoint":"testing","exportName":"handleAcpCommand","importSpecifier":"openclaw/plugin-sdk/testing","kind":"const","recordType":"export","sourceLine":75,"sourcePath":"src/auto-reply/reply/commands-acp.ts"} -{"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"testing","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":147,"sourcePath":"src/channels/plugins/types.core.ts"} -{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext;","entrypoint":"testing","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":285,"sourcePath":"src/channels/plugins/types.adapters.ts"} +{"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"testing","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":154,"sourcePath":"src/channels/plugins/types.core.ts"} +{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext;","entrypoint":"testing","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":287,"sourcePath":"src/channels/plugins/types.adapters.ts"} {"declaration":"export type CliMockOutputRuntime = CliMockOutputRuntime;","entrypoint":"testing","exportName":"CliMockOutputRuntime","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":5,"sourcePath":"src/cli/test-runtime-capture.ts"} {"declaration":"export type CliRuntimeCapture = CliRuntimeCapture;","entrypoint":"testing","exportName":"CliRuntimeCapture","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/cli/test-runtime-capture.ts"} {"declaration":"export type FetchMock = FetchMock;","entrypoint":"testing","exportName":"FetchMock","importSpecifier":"openclaw/plugin-sdk/testing","kind":"type","recordType":"export","sourceLine":1,"sourcePath":"src/test-utils/fetch-mock.ts"} diff --git a/extensions/anthropic/contract-api.ts b/extensions/anthropic/contract-api.ts new file mode 100644 index 00000000000..4878de5c363 --- /dev/null +++ b/extensions/anthropic/contract-api.ts @@ -0,0 +1,8 @@ +export { + createAnthropicBetaHeadersWrapper, + createAnthropicFastModeWrapper, + createAnthropicServiceTierWrapper, + resolveAnthropicBetas, + resolveAnthropicFastMode, + resolveAnthropicServiceTier, +} from "./stream-wrappers.js"; diff --git a/extensions/discord/contract-api.ts b/extensions/discord/contract-api.ts new file mode 100644 index 00000000000..76ed3778504 --- /dev/null +++ b/extensions/discord/contract-api.ts @@ -0,0 +1,16 @@ +export { createThreadBindingManager } from "./src/monitor/thread-bindings.manager.js"; +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-config-contract.js"; +export { + unsupportedSecretRefSurfacePatterns, + collectUnsupportedSecretRefConfigCandidates, +} from "./src/security-contract.js"; +export { deriveLegacySessionChatType } from "./src/session-contract.js"; +export type { + DiscordInteractiveHandlerContext, + DiscordInteractiveHandlerRegistration, +} from "./src/interactive-dispatch.js"; +export { collectDiscordSecurityAuditFindings } from "./src/security-audit.js"; diff --git a/extensions/discord/src/doctor-contract.ts b/extensions/discord/src/doctor-contract.ts new file mode 100644 index 00000000000..30643f668cc --- /dev/null +++ b/extensions/discord/src/doctor-contract.ts @@ -0,0 +1,292 @@ +import type { + ChannelDoctorConfigMutation, + ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js"; + +function asObjectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function normalizeDiscordStreamingAliases(params: { + entry: Record; + pathPrefix: string; + changes: string[]; +}): { entry: Record; changed: boolean } { + let updated = params.entry; + const hadLegacyStreamMode = updated.streamMode !== undefined; + const beforeStreaming = updated.streaming; + const resolved = resolveDiscordPreviewStreamMode(updated); + const shouldNormalize = + hadLegacyStreamMode || + typeof beforeStreaming === "boolean" || + (typeof beforeStreaming === "string" && beforeStreaming !== resolved); + if (!shouldNormalize) { + return { entry: updated, changed: false }; + } + + let changed = false; + if (beforeStreaming !== resolved) { + updated = { ...updated, streaming: resolved }; + changed = true; + } + if (hadLegacyStreamMode) { + const { streamMode: _ignored, ...rest } = updated; + updated = rest; + changed = true; + params.changes.push( + `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`, + ); + } + if (typeof beforeStreaming === "boolean") { + params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`); + } else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) { + params.changes.push( + `Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`, + ); + } + if ( + params.pathPrefix.startsWith("channels.discord") && + resolved === "off" && + hadLegacyStreamMode + ) { + params.changes.push( + `${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`, + ); + } + return { entry: updated, changed }; +} + +function hasLegacyDiscordStreamingAliases(value: unknown): boolean { + const entry = asObjectRecord(value); + if (!entry) { + return false; + } + return ( + entry.streamMode !== undefined || + typeof entry.streaming === "boolean" || + (typeof entry.streaming === "string" && + entry.streaming !== resolveDiscordPreviewStreamMode(entry)) + ); +} + +function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean { + const accounts = asObjectRecord(value); + if (!accounts) { + return false; + } + return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account)); +} + +const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const; + +function hasLegacyTtsProviderKeys(value: unknown): boolean { + const tts = asObjectRecord(value); + if (!tts) { + return false; + } + return LEGACY_TTS_PROVIDER_KEYS.some((key) => Object.prototype.hasOwnProperty.call(tts, key)); +} + +function hasLegacyDiscordAccountTtsProviderKeys(value: unknown): boolean { + const accounts = asObjectRecord(value); + if (!accounts) { + return false; + } + return Object.values(accounts).some((accountValue) => { + const account = asObjectRecord(accountValue); + const voice = asObjectRecord(account?.voice); + return hasLegacyTtsProviderKeys(voice?.tts); + }); +} + +function mergeMissing(target: Record, source: Record) { + for (const [key, value] of Object.entries(source)) { + if (value === undefined) { + continue; + } + const existing = target[key]; + if (existing === undefined) { + target[key] = value; + continue; + } + if ( + existing && + typeof existing === "object" && + !Array.isArray(existing) && + value && + typeof value === "object" && + !Array.isArray(value) + ) { + mergeMissing(existing as Record, value as Record); + } + } +} + +function getOrCreateTtsProviders(tts: Record): Record { + const providers = asObjectRecord(tts.providers) ?? {}; + tts.providers = providers; + return providers; +} + +function mergeLegacyTtsProviderConfig( + tts: Record, + legacyKey: string, + providerId: string, +): boolean { + const legacyValue = asObjectRecord(tts[legacyKey]); + if (!legacyValue) { + return false; + } + const providers = getOrCreateTtsProviders(tts); + const existing = asObjectRecord(providers[providerId]) ?? {}; + const merged = structuredClone(existing); + mergeMissing(merged, legacyValue); + providers[providerId] = merged; + delete tts[legacyKey]; + return true; +} + +function migrateLegacyTtsConfig( + tts: Record | null, + pathLabel: string, + changes: string[], +): boolean { + if (!tts) { + return false; + } + let changed = false; + if (mergeLegacyTtsProviderConfig(tts, "openai", "openai")) { + changes.push(`Moved ${pathLabel}.openai → ${pathLabel}.providers.openai.`); + changed = true; + } + if (mergeLegacyTtsProviderConfig(tts, "elevenlabs", "elevenlabs")) { + changes.push(`Moved ${pathLabel}.elevenlabs → ${pathLabel}.providers.elevenlabs.`); + changed = true; + } + if (mergeLegacyTtsProviderConfig(tts, "microsoft", "microsoft")) { + changes.push(`Moved ${pathLabel}.microsoft → ${pathLabel}.providers.microsoft.`); + changed = true; + } + if (mergeLegacyTtsProviderConfig(tts, "edge", "microsoft")) { + changes.push(`Moved ${pathLabel}.edge → ${pathLabel}.providers.microsoft.`); + changed = true; + } + return changed; +} + +export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "discord"], + message: + "channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.", + match: hasLegacyDiscordStreamingAliases, + }, + { + path: ["channels", "discord", "accounts"], + message: + "channels.discord.accounts..streamMode and boolean channels.discord.accounts..streaming are legacy; use channels.discord.accounts..streaming.", + match: hasLegacyDiscordAccountStreamingAliases, + }, + { + path: ["channels", "discord", "voice", "tts"], + message: + "channels.discord.voice.tts. keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.voice.tts.providers. (auto-migrated on load).", + match: hasLegacyTtsProviderKeys, + }, + { + path: ["channels", "discord", "accounts"], + message: + "channels.discord.accounts..voice.tts. keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.accounts..voice.tts.providers. (auto-migrated on load).", + match: hasLegacyDiscordAccountTtsProviderKeys, + }, +]; + +export function normalizeCompatibilityConfig({ + cfg, +}: { + cfg: OpenClawConfig; +}): ChannelDoctorConfigMutation { + const rawEntry = asObjectRecord((cfg.channels as Record | undefined)?.discord); + if (!rawEntry) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updated = rawEntry; + let changed = false; + + const streaming = normalizeDiscordStreamingAliases({ + entry: updated, + pathPrefix: "channels.discord", + changes, + }); + updated = streaming.entry; + changed = changed || streaming.changed; + + const rawAccounts = asObjectRecord(updated.accounts); + if (rawAccounts) { + let accountsChanged = false; + const accounts = { ...rawAccounts }; + for (const [accountId, rawAccount] of Object.entries(rawAccounts)) { + const account = asObjectRecord(rawAccount); + if (!account) { + continue; + } + const accountStreaming = normalizeDiscordStreamingAliases({ + entry: account, + pathPrefix: `channels.discord.accounts.${accountId}`, + changes, + }); + if (accountStreaming.changed) { + accounts[accountId] = accountStreaming.entry; + accountsChanged = true; + } + const accountVoice = asObjectRecord(accountStreaming.entry.voice); + if ( + accountVoice && + migrateLegacyTtsConfig( + asObjectRecord(accountVoice.tts), + `channels.discord.accounts.${accountId}.voice.tts`, + changes, + ) + ) { + accounts[accountId] = { + ...accountStreaming.entry, + voice: accountVoice, + }; + accountsChanged = true; + } + } + if (accountsChanged) { + updated = { ...updated, accounts }; + changed = true; + } + } + + const voice = asObjectRecord(updated.voice); + if ( + voice && + migrateLegacyTtsConfig(asObjectRecord(voice.tts), "channels.discord.voice.tts", changes) + ) { + updated = { ...updated, voice }; + changed = true; + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + discord: updated, + } as OpenClawConfig["channels"], + }, + changes, + }; +} diff --git a/extensions/discord/src/doctor.ts b/extensions/discord/src/doctor.ts index a037c649d78..1d48c525f65 100644 --- a/extensions/discord/src/doctor.ts +++ b/extensions/discord/src/doctor.ts @@ -1,15 +1,12 @@ import { type ChannelDoctorAdapter, type ChannelDoctorConfigMutation, + type ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; -import { - resolveDiscordPreviewStreamMode, - type OpenClawConfig, -} from "openclaw/plugin-sdk/config-runtime"; -import { - collectProviderDangerousNameMatchingScopes, - isDiscordMutableAllowEntry, -} from "openclaw/plugin-sdk/runtime"; +import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime"; +import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js"; +import { isDiscordMutableAllowEntry } from "./security-audit.js"; type DiscordNumericIdHit = { path: string; entry: number; safe: boolean }; @@ -517,11 +514,48 @@ function collectDiscordMutableAllowlistWarnings(cfg: OpenClawConfig): string[] { ]; } +function hasLegacyDiscordStreamingAliases(value: unknown): boolean { + const entry = asObjectRecord(value); + if (!entry) { + return false; + } + return ( + entry.streamMode !== undefined || + typeof entry.streaming === "boolean" || + (typeof entry.streaming === "string" && + entry.streaming !== resolveDiscordPreviewStreamMode(entry)) + ); +} + +function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean { + const accounts = asObjectRecord(value); + if (!accounts) { + return false; + } + return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account)); +} + +const DISCORD_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "discord"], + message: + "channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.", + match: hasLegacyDiscordStreamingAliases, + }, + { + path: ["channels", "discord", "accounts"], + message: + "channels.discord.accounts..streamMode and boolean channels.discord.accounts..streaming are legacy; use channels.discord.accounts..streaming.", + match: hasLegacyDiscordAccountStreamingAliases, + }, +]; + export const discordDoctor: ChannelDoctorAdapter = { dmAllowFromMode: "topOrNested", groupModel: "route", groupAllowFromFallbackToAllowFrom: false, warnOnEmptyGroupSenderAllowlist: false, + legacyConfigRules: DISCORD_LEGACY_CONFIG_RULES, normalizeCompatibilityConfig: ({ cfg }) => normalizeDiscordCompatibilityConfig(cfg), collectPreviewWarnings: ({ cfg, doctorFixCommand }) => collectDiscordNumericIdWarnings({ diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 31bc9832c0a..cb10bd24fcf 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -16,7 +16,6 @@ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pi import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime"; -import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; @@ -40,6 +39,7 @@ import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js"; import { createDiscordDraftStream } from "../draft-stream.js"; +import { resolveDiscordPreviewStreamMode } from "../preview-streaming.js"; import { removeReactionDiscord } from "../send.js"; import { editMessageDiscord } from "../send.messages.js"; import { diff --git a/extensions/discord/src/preview-streaming.ts b/extensions/discord/src/preview-streaming.ts new file mode 100644 index 00000000000..4734840a481 --- /dev/null +++ b/extensions/discord/src/preview-streaming.ts @@ -0,0 +1,51 @@ +export type DiscordPreviewStreamMode = "off" | "partial" | "block"; + +function normalizeStreamingMode(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + return normalized || null; +} + +function parseStreamingMode(value: unknown): "off" | "partial" | "block" | "progress" | null { + const normalized = normalizeStreamingMode(value); + if ( + normalized === "off" || + normalized === "partial" || + normalized === "block" || + normalized === "progress" + ) { + return normalized; + } + return null; +} + +function parseDiscordPreviewStreamMode(value: unknown): DiscordPreviewStreamMode | null { + const parsed = parseStreamingMode(value); + if (!parsed) { + return null; + } + return parsed === "progress" ? "partial" : parsed; +} + +export function resolveDiscordPreviewStreamMode( + params: { + streamMode?: unknown; + streaming?: unknown; + } = {}, +): DiscordPreviewStreamMode { + const parsedStreaming = parseDiscordPreviewStreamMode(params.streaming); + if (parsedStreaming) { + return parsedStreaming; + } + + const legacy = parseDiscordPreviewStreamMode(params.streamMode); + if (legacy) { + return legacy; + } + if (typeof params.streaming === "boolean") { + return params.streaming ? "partial" : "off"; + } + return "off"; +} diff --git a/extensions/discord/src/secret-config-contract.ts b/extensions/discord/src/secret-config-contract.ts new file mode 100644 index 00000000000..88e1c8948c3 --- /dev/null +++ b/extensions/discord/src/secret-config-contract.ts @@ -0,0 +1,140 @@ +import { + collectNestedChannelFieldAssignments, + collectNestedChannelTtsAssignments, + collectSimpleChannelFieldAssignments, + getChannelSurface, + isBaseFieldActiveForChannelSurface, + isEnabledFlag, + isRecord, + type ResolverContext, + type SecretDefaults, + type SecretTargetRegistryEntry, +} from "openclaw/plugin-sdk/security-runtime"; + +export const secretTargetRegistryEntries = [ + { + id: "channels.discord.accounts.*.pluralkit.token", + targetType: "channels.discord.accounts.*.pluralkit.token", + configFile: "openclaw.json", + pathPattern: "channels.discord.accounts.*.pluralkit.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.discord.accounts.*.token", + targetType: "channels.discord.accounts.*.token", + configFile: "openclaw.json", + pathPattern: "channels.discord.accounts.*.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.discord.accounts.*.voice.tts.providers.*.apiKey", + targetType: "channels.discord.accounts.*.voice.tts.providers.*.apiKey", + configFile: "openclaw.json", + pathPattern: "channels.discord.accounts.*.voice.tts.providers.*.apiKey", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + providerIdPathSegmentIndex: 6, + }, + { + id: "channels.discord.pluralkit.token", + targetType: "channels.discord.pluralkit.token", + configFile: "openclaw.json", + pathPattern: "channels.discord.pluralkit.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.discord.token", + targetType: "channels.discord.token", + configFile: "openclaw.json", + pathPattern: "channels.discord.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.discord.voice.tts.providers.*.apiKey", + targetType: "channels.discord.voice.tts.providers.*.apiKey", + configFile: "openclaw.json", + pathPattern: "channels.discord.voice.tts.providers.*.apiKey", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + providerIdPathSegmentIndex: 4, + }, +] satisfies SecretTargetRegistryEntry[]; + +export function collectRuntimeConfigAssignments(params: { + config: { channels?: Record }; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const resolved = getChannelSurface(params.config, "discord"); + if (!resolved) { + return; + } + const { channel: discord, surface } = resolved; + collectSimpleChannelFieldAssignments({ + channelKey: "discord", + field: "token", + channel: discord, + surface, + defaults: params.defaults, + context: params.context, + topInactiveReason: "no enabled account inherits this top-level Discord token.", + accountInactiveReason: "Discord account is disabled.", + }); + collectNestedChannelFieldAssignments({ + channelKey: "discord", + nestedKey: "pluralkit", + field: "token", + channel: discord, + surface, + defaults: params.defaults, + context: params.context, + topLevelActive: + isBaseFieldActiveForChannelSurface(surface, "pluralkit") && + isRecord(discord.pluralkit) && + isEnabledFlag(discord.pluralkit), + topInactiveReason: + "no enabled Discord surface inherits this top-level PluralKit config or PluralKit is disabled.", + accountActive: ({ account, enabled }) => + enabled && isRecord(account.pluralkit) && isEnabledFlag(account.pluralkit), + accountInactiveReason: "Discord account is disabled or PluralKit is disabled for this account.", + }); + collectNestedChannelTtsAssignments({ + channelKey: "discord", + nestedKey: "voice", + channel: discord, + surface, + defaults: params.defaults, + context: params.context, + topLevelActive: + isBaseFieldActiveForChannelSurface(surface, "voice") && + isRecord(discord.voice) && + isEnabledFlag(discord.voice), + topInactiveReason: + "no enabled Discord surface inherits this top-level voice config or voice is disabled.", + accountActive: ({ account, enabled }) => + enabled && isRecord(account.voice) && isEnabledFlag(account.voice), + accountInactiveReason: "Discord account is disabled or voice is disabled for this account.", + }); +} diff --git a/extensions/discord/src/security-audit.ts b/extensions/discord/src/security-audit.ts index 124fafb585d..e5c7a3dbc52 100644 --- a/extensions/discord/src/security-audit.ts +++ b/extensions/discord/src/security-audit.ts @@ -21,7 +21,7 @@ function coerceNativeSetting(value: unknown): boolean | "auto" | undefined { return undefined; } -function isDiscordMutableAllowEntry(raw: string): boolean { +export function isDiscordMutableAllowEntry(raw: string): boolean { const text = raw.trim(); if (!text || text === "*") { return false; diff --git a/extensions/discord/src/security-contract.ts b/extensions/discord/src/security-contract.ts new file mode 100644 index 00000000000..ad56803fbfc --- /dev/null +++ b/extensions/discord/src/security-contract.ts @@ -0,0 +1,46 @@ +type UnsupportedSecretRefConfigCandidate = { + path: string; + value: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export const unsupportedSecretRefSurfacePatterns = [ + "channels.discord.threadBindings.webhookToken", + "channels.discord.accounts.*.threadBindings.webhookToken", +] as const; + +export function collectUnsupportedSecretRefConfigCandidates( + raw: unknown, +): UnsupportedSecretRefConfigCandidate[] { + if (!isRecord(raw.channels) || !isRecord(raw.channels.discord)) { + return []; + } + + const candidates: UnsupportedSecretRefConfigCandidate[] = []; + const discord = raw.channels.discord; + const threadBindings = isRecord(discord.threadBindings) ? discord.threadBindings : null; + if (threadBindings) { + candidates.push({ + path: "channels.discord.threadBindings.webhookToken", + value: threadBindings.webhookToken, + }); + } + + const accounts = isRecord(discord.accounts) ? discord.accounts : null; + if (!accounts) { + return candidates; + } + for (const [accountId, account] of Object.entries(accounts)) { + if (!isRecord(account) || !isRecord(account.threadBindings)) { + continue; + } + candidates.push({ + path: `channels.discord.accounts.${accountId}.threadBindings.webhookToken`, + value: account.threadBindings.webhookToken, + }); + } + return candidates; +} diff --git a/extensions/discord/src/session-contract.ts b/extensions/discord/src/session-contract.ts new file mode 100644 index 00000000000..00b66226902 --- /dev/null +++ b/extensions/discord/src/session-contract.ts @@ -0,0 +1,3 @@ +export function deriveLegacySessionChatType(sessionKey: string): "channel" | undefined { + return /^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(sessionKey) ? "channel" : undefined; +} diff --git a/extensions/feishu/contract-api.ts b/extensions/feishu/contract-api.ts new file mode 100644 index 00000000000..81f505ef24f --- /dev/null +++ b/extensions/feishu/contract-api.ts @@ -0,0 +1,6 @@ +export { createFeishuThreadBindingManager } from "./src/thread-bindings.js"; +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; +export { messageActionTargetAliases } from "./src/message-action-contract.js"; diff --git a/extensions/feishu/runtime-api.ts b/extensions/feishu/runtime-api.ts index fd73a1fcd62..72043e83c79 100644 --- a/extensions/feishu/runtime-api.ts +++ b/extensions/feishu/runtime-api.ts @@ -1,25 +1,48 @@ // Private runtime barrel for the bundled Feishu extension. -// Keep this barrel thin and aligned with the local extension surface. +// Keep this barrel thin and generic-only. export type { + AllowlistMatch, + AnyAgentTool, + BaseProbeResult, + ChannelGroupContext, ChannelMessageActionName, ChannelMeta, ChannelOutboundAdapter, - OpenClawConfig as ClawdbotConfig, + ChannelPlugin, + HistoryEntry, OpenClawConfig, OpenClawPluginApi, + OutboundIdentity, PluginRuntime, - RuntimeEnv, -} from "openclaw/plugin-sdk/feishu"; + ReplyPayload, +} from "openclaw/plugin-sdk/core"; +export type { OpenClawConfig as ClawdbotConfig } from "openclaw/plugin-sdk/core"; +export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-runtime"; export { DEFAULT_ACCOUNT_ID, - PAIRING_APPROVED_MESSAGE, buildChannelConfigSchema, - buildProbeChannelStatusSummary, createActionGate, + createDedupeCache, +} from "openclaw/plugin-sdk/core"; +export { + PAIRING_APPROVED_MESSAGE, + buildProbeChannelStatusSummary, createDefaultChannelRuntimeState, -} from "openclaw/plugin-sdk/feishu"; -export * from "openclaw/plugin-sdk/feishu"; +} from "openclaw/plugin-sdk/channel-status"; +export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload"; +export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; +export { createReplyPrefixContext } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { + evaluateSupplementalContextVisibility, + filterSupplementalContextItems, + resolveChannelContextVisibilityMode, +} from "openclaw/plugin-sdk/config-runtime"; +export { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store"; +export { createPersistentDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; +export { normalizeAgentId } from "openclaw/plugin-sdk/routing"; +export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; export { isRequestBodyLimitError, readRequestBodyWithLimit, diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 388501cbb82..5a1ec86146b 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -51,6 +51,7 @@ import type { import { createFeishuClient } from "./client.js"; import { FeishuConfigSchema } from "./config-schema.js"; import { + buildFeishuModelOverrideParentCandidates, buildFeishuConversationId, parseFeishuConversationId, parseFeishuDirectConversationId, @@ -59,6 +60,7 @@ import { import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { getFeishuRuntime } from "./runtime.js"; +import { collectFeishuSecurityAuditFindings } from "./security-audit.js"; import { resolveFeishuParentConversationCandidates, resolveFeishuSessionConversation, @@ -146,7 +148,9 @@ function describeFeishuMessageTool({ NonNullable >[0]): ChannelMessageToolDiscovery { const enabledAccounts = accountId - ? [resolveFeishuAccount({ cfg, accountId })].filter((account) => account.enabled && account.configured) + ? [resolveFeishuAccount({ cfg, accountId })].filter( + (account) => account.enabled && account.configured, + ) : listEnabledFeishuAccounts(cfg); const enabled = enabledAccounts.length > 0 || @@ -179,9 +183,9 @@ function describeFeishuMessageTool({ "channel-list", ]); if ( - (accountId + accountId ? enabledAccounts.some((account) => isFeishuReactionsActionEnabled({ cfg, account })) - : areAnyFeishuReactionActionsEnabled(cfg)) + : areAnyFeishuReactionActionsEnabled(cfg) ) { actions.add("react"); actions.add("reactions"); @@ -567,6 +571,8 @@ export const feishuPlugin: ChannelPlugin + buildFeishuModelOverrideParentCandidates(parentConversationId), }, mentions: { stripPatterns: () => ['[^<]*'], @@ -1180,6 +1186,7 @@ export const feishuPlugin: ChannelPlugin(collectFeishuSecurityWarnings), + collectAuditFindings: ({ cfg }) => collectFeishuSecurityAuditFindings({ cfg }), }, pairing: { text: { diff --git a/extensions/feishu/src/conversation-id.ts b/extensions/feishu/src/conversation-id.ts index eccc53657e0..ff9304f23b9 100644 --- a/extensions/feishu/src/conversation-id.ts +++ b/extensions/feishu/src/conversation-id.ts @@ -166,3 +166,32 @@ export function parseFeishuConversationId(params: { scope: "group", }; } + +export function buildFeishuModelOverrideParentCandidates( + parentConversationId?: string | null, +): string[] { + const rawId = normalizeText(parentConversationId); + if (!rawId) { + return []; + } + const topicSenderMatch = rawId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i); + if (topicSenderMatch) { + const chatId = topicSenderMatch[1]?.trim().toLowerCase(); + const topicId = topicSenderMatch[2]?.trim().toLowerCase(); + if (chatId && topicId) { + return [`${chatId}:topic:${topicId}`, chatId]; + } + return []; + } + const topicMatch = rawId.match(/^(.+):topic:([^:]+)$/i); + if (topicMatch) { + const chatId = topicMatch[1]?.trim().toLowerCase(); + return chatId ? [chatId] : []; + } + const senderMatch = rawId.match(/^(.+):sender:([^:]+)$/i); + if (senderMatch) { + const chatId = senderMatch[1]?.trim().toLowerCase(); + return chatId ? [chatId] : []; + } + return []; +} diff --git a/extensions/feishu/src/message-action-contract.ts b/extensions/feishu/src/message-action-contract.ts new file mode 100644 index 00000000000..c8efe69a6e9 --- /dev/null +++ b/extensions/feishu/src/message-action-contract.ts @@ -0,0 +1,13 @@ +import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract"; + +type MessageActionTargetAliasSpec = { + aliases: string[]; +}; + +export const messageActionTargetAliases = { + read: { aliases: ["messageId"] }, + pin: { aliases: ["messageId"] }, + unpin: { aliases: ["messageId"] }, + "list-pins": { aliases: ["chatId"] }, + "channel-info": { aliases: ["chatId"] }, +} satisfies Partial>; diff --git a/extensions/feishu/src/secret-contract.ts b/extensions/feishu/src/secret-contract.ts new file mode 100644 index 00000000000..fd10b6c8f95 --- /dev/null +++ b/extensions/feishu/src/secret-contract.ts @@ -0,0 +1,140 @@ +import { + collectConditionalChannelFieldAssignments, + collectSimpleChannelFieldAssignments, + getChannelSurface, + hasOwnProperty, + normalizeSecretStringValue, + type ResolverContext, + type SecretDefaults, + type SecretTargetRegistryEntry, +} from "openclaw/plugin-sdk/security-runtime"; + +export const secretTargetRegistryEntries = [ + { + id: "channels.feishu.accounts.*.appSecret", + targetType: "channels.feishu.accounts.*.appSecret", + configFile: "openclaw.json", + pathPattern: "channels.feishu.accounts.*.appSecret", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.feishu.accounts.*.encryptKey", + targetType: "channels.feishu.accounts.*.encryptKey", + configFile: "openclaw.json", + pathPattern: "channels.feishu.accounts.*.encryptKey", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.feishu.accounts.*.verificationToken", + targetType: "channels.feishu.accounts.*.verificationToken", + configFile: "openclaw.json", + pathPattern: "channels.feishu.accounts.*.verificationToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.feishu.appSecret", + targetType: "channels.feishu.appSecret", + configFile: "openclaw.json", + pathPattern: "channels.feishu.appSecret", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.feishu.encryptKey", + targetType: "channels.feishu.encryptKey", + configFile: "openclaw.json", + pathPattern: "channels.feishu.encryptKey", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.feishu.verificationToken", + targetType: "channels.feishu.verificationToken", + configFile: "openclaw.json", + pathPattern: "channels.feishu.verificationToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, +] satisfies SecretTargetRegistryEntry[]; + +export function collectRuntimeConfigAssignments(params: { + config: { channels?: Record }; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const resolved = getChannelSurface(params.config, "feishu"); + if (!resolved) { + return; + } + const { channel: feishu, surface } = resolved; + collectSimpleChannelFieldAssignments({ + channelKey: "feishu", + field: "appSecret", + channel: feishu, + surface, + defaults: params.defaults, + context: params.context, + topInactiveReason: "no enabled account inherits this top-level Feishu appSecret.", + accountInactiveReason: "Feishu account is disabled.", + }); + const baseConnectionMode = + normalizeSecretStringValue(feishu.connectionMode) === "webhook" ? "webhook" : "websocket"; + const resolveAccountMode = (account: Record) => + hasOwnProperty(account, "connectionMode") + ? normalizeSecretStringValue(account.connectionMode) + : baseConnectionMode; + collectConditionalChannelFieldAssignments({ + channelKey: "feishu", + field: "encryptKey", + channel: feishu, + surface, + defaults: params.defaults, + context: params.context, + topLevelActiveWithoutAccounts: baseConnectionMode === "webhook", + topLevelInheritedAccountActive: ({ account, enabled }) => + enabled && + !hasOwnProperty(account, "encryptKey") && + resolveAccountMode(account) === "webhook", + accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook", + topInactiveReason: "no enabled Feishu webhook-mode surface inherits this top-level encryptKey.", + accountInactiveReason: "Feishu account is disabled or not running in webhook mode.", + }); + collectConditionalChannelFieldAssignments({ + channelKey: "feishu", + field: "verificationToken", + channel: feishu, + surface, + defaults: params.defaults, + context: params.context, + topLevelActiveWithoutAccounts: baseConnectionMode === "webhook", + topLevelInheritedAccountActive: ({ account, enabled }) => + enabled && + !hasOwnProperty(account, "verificationToken") && + resolveAccountMode(account) === "webhook", + accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook", + topInactiveReason: + "no enabled Feishu webhook-mode surface inherits this top-level verificationToken.", + accountInactiveReason: "Feishu account is disabled or not running in webhook mode.", + }); +} diff --git a/extensions/feishu/src/security-audit.ts b/extensions/feishu/src/security-audit.ts new file mode 100644 index 00000000000..ce2c8e25ff2 --- /dev/null +++ b/extensions/feishu/src/security-audit.ts @@ -0,0 +1,70 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/setup"; + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function hasNonEmptyString(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function isFeishuDocToolEnabled(cfg: OpenClawConfig): boolean { + const channels = asRecord(cfg.channels); + const feishu = asRecord(channels?.feishu); + if (!feishu || feishu.enabled === false) { + return false; + } + + const baseTools = asRecord(feishu.tools); + const baseDocEnabled = baseTools?.doc !== false; + const baseAppId = hasNonEmptyString(feishu.appId); + const baseAppSecret = hasConfiguredSecretInput(feishu.appSecret, cfg.secrets?.defaults); + const baseConfigured = baseAppId && baseAppSecret; + + const accounts = asRecord(feishu.accounts); + if (!accounts || Object.keys(accounts).length === 0) { + return baseDocEnabled && baseConfigured; + } + + for (const accountValue of Object.values(accounts)) { + const account = asRecord(accountValue) ?? {}; + if (account.enabled === false) { + continue; + } + const accountTools = asRecord(account.tools); + const effectiveTools = accountTools ?? baseTools; + const docEnabled = effectiveTools?.doc !== false; + if (!docEnabled) { + continue; + } + const accountConfigured = + (hasNonEmptyString(account.appId) || baseAppId) && + (hasConfiguredSecretInput(account.appSecret, cfg.secrets?.defaults) || baseAppSecret); + if (accountConfigured) { + return true; + } + } + + return false; +} + +export function collectFeishuSecurityAuditFindings(params: { cfg: OpenClawConfig }) { + if (!isFeishuDocToolEnabled(params.cfg)) { + return []; + } + return [ + { + checkId: "channels.feishu.doc_owner_open_id", + severity: "warn" as const, + title: "Feishu doc create can grant requester permissions", + detail: + 'channels.feishu tools include "doc"; feishu_doc action "create" can grant document access to the trusted requesting Feishu user.', + remediation: + "Disable channels.feishu.tools.doc when not needed, and restrict tool access for untrusted prompts.", + }, + ]; +} diff --git a/extensions/googlechat/contract-api.ts b/extensions/googlechat/contract-api.ts new file mode 100644 index 00000000000..bc8f64f050f --- /dev/null +++ b/extensions/googlechat/contract-api.ts @@ -0,0 +1,4 @@ +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 68c4db4e014..d688addb3d1 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -50,6 +50,7 @@ import { type OpenClawConfig, type ResolvedGoogleChatAccount, } from "./channel.deps.runtime.js"; +import { collectGoogleChatMutableAllowlistWarnings } from "./doctor.js"; import { resolveGoogleChatGroupRequireMention } from "./group-policy.js"; import { getGoogleChatRuntime } from "./runtime.js"; import { googlechatSetupAdapter } from "./setup-core.js"; @@ -218,6 +219,7 @@ export const googlechatPlugin = createChatChannelPlugin({ groupModel: "route", groupAllowFromFallbackToAllowFrom: false, warnOnEmptyGroupSenderAllowlist: false, + collectMutableAllowlistWarnings: collectGoogleChatMutableAllowlistWarnings, }, status: createComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), diff --git a/extensions/googlechat/src/doctor.ts b/extensions/googlechat/src/doctor.ts new file mode 100644 index 00000000000..6c8f34f8b55 --- /dev/null +++ b/extensions/googlechat/src/doctor.ts @@ -0,0 +1,57 @@ +import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; + +function asObjectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function isGoogleChatMutableAllowEntry(raw: string): boolean { + const text = raw.trim(); + if (!text || text === "*") { + return false; + } + + const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim(); + if (!withoutPrefix) { + return false; + } + + const withoutUsers = withoutPrefix.replace(/^users\//i, ""); + return withoutUsers.includes("@"); +} + +export const collectGoogleChatMutableAllowlistWarnings = + createDangerousNameMatchingMutableAllowlistWarningCollector({ + channel: "googlechat", + detector: isGoogleChatMutableAllowEntry, + collectLists: (scope) => { + const lists = [ + { + pathLabel: `${scope.prefix}.groupAllowFrom`, + list: scope.account.groupAllowFrom, + }, + ]; + const dm = asObjectRecord(scope.account.dm); + if (dm) { + lists.push({ + pathLabel: `${scope.prefix}.dm.allowFrom`, + list: dm.allowFrom, + }); + } + const groups = asObjectRecord(scope.account.groups); + if (groups) { + for (const [groupKey, groupRaw] of Object.entries(groups)) { + const group = asObjectRecord(groupRaw); + if (!group) { + continue; + } + lists.push({ + pathLabel: `${scope.prefix}.groups.${groupKey}.users`, + list: group.users, + }); + } + } + return lists; + }, + }); diff --git a/extensions/googlechat/src/secret-contract.ts b/extensions/googlechat/src/secret-contract.ts new file mode 100644 index 00000000000..188486d6104 --- /dev/null +++ b/extensions/googlechat/src/secret-contract.ts @@ -0,0 +1,156 @@ +import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime"; +import { + getChannelSurface, + hasOwnProperty, + pushAssignment, + pushInactiveSurfaceWarning, + pushWarning, + resolveChannelAccountSurface, + type ResolverContext, + type SecretDefaults, + type SecretTargetRegistryEntry, +} from "openclaw/plugin-sdk/security-runtime"; + +type GoogleChatAccountLike = { + serviceAccount?: unknown; + serviceAccountRef?: unknown; + accounts?: Record; +}; + +export const secretTargetRegistryEntries = [ + { + id: "channels.googlechat.accounts.*.serviceAccount", + targetType: "channels.googlechat.serviceAccount", + targetTypeAliases: ["channels.googlechat.accounts.*.serviceAccount"], + configFile: "openclaw.json", + pathPattern: "channels.googlechat.accounts.*.serviceAccount", + refPathPattern: "channels.googlechat.accounts.*.serviceAccountRef", + secretShape: "sibling_ref", + expectedResolvedValue: "string-or-object", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + accountIdPathSegmentIndex: 3, + }, + { + id: "channels.googlechat.serviceAccount", + targetType: "channels.googlechat.serviceAccount", + configFile: "openclaw.json", + pathPattern: "channels.googlechat.serviceAccount", + refPathPattern: "channels.googlechat.serviceAccountRef", + secretShape: "sibling_ref", + expectedResolvedValue: "string-or-object", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, +] satisfies SecretTargetRegistryEntry[]; + +function resolveSecretInputRef(params: { + value: unknown; + refValue?: unknown; + defaults?: SecretDefaults; +}) { + const explicitRef = coerceSecretRef(params.refValue, params.defaults); + const inlineRef = explicitRef ? null : coerceSecretRef(params.value, params.defaults); + return { + explicitRef, + inlineRef, + ref: explicitRef ?? inlineRef, + }; +} + +function collectGoogleChatAccountAssignment(params: { + target: GoogleChatAccountLike; + path: string; + defaults: SecretDefaults | undefined; + context: ResolverContext; + active?: boolean; + inactiveReason?: string; +}): void { + const { explicitRef, ref } = resolveSecretInputRef({ + value: params.target.serviceAccount, + refValue: params.target.serviceAccountRef, + defaults: params.defaults, + }); + if (!ref) { + return; + } + if (params.active === false) { + pushInactiveSurfaceWarning({ + context: params.context, + path: `${params.path}.serviceAccount`, + details: params.inactiveReason, + }); + return; + } + if ( + explicitRef && + params.target.serviceAccount !== undefined && + !coerceSecretRef(params.target.serviceAccount, params.defaults) + ) { + pushWarning(params.context, { + code: "SECRETS_REF_OVERRIDES_PLAINTEXT", + path: params.path, + message: `${params.path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`, + }); + } + pushAssignment(params.context, { + ref, + path: `${params.path}.serviceAccount`, + expected: "string-or-object", + apply: (value) => { + params.target.serviceAccount = value; + }, + }); +} + +export function collectRuntimeConfigAssignments(params: { + config: { channels?: Record }; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const resolved = getChannelSurface(params.config, "googlechat"); + if (!resolved) { + return; + } + const googleChat = resolved.channel as GoogleChatAccountLike; + const surface = resolveChannelAccountSurface(googleChat as Record); + const topLevelServiceAccountActive = !surface.channelEnabled + ? false + : !surface.hasExplicitAccounts + ? true + : surface.accounts.some( + ({ account, enabled }) => + enabled && + !hasOwnProperty(account, "serviceAccount") && + !hasOwnProperty(account, "serviceAccountRef"), + ); + collectGoogleChatAccountAssignment({ + target: googleChat, + path: "channels.googlechat", + defaults: params.defaults, + context: params.context, + active: topLevelServiceAccountActive, + inactiveReason: "no enabled account inherits this top-level Google Chat serviceAccount.", + }); + if (!surface.hasExplicitAccounts) { + return; + } + for (const { accountId, account, enabled } of surface.accounts) { + if ( + !hasOwnProperty(account, "serviceAccount") && + !hasOwnProperty(account, "serviceAccountRef") + ) { + continue; + } + collectGoogleChatAccountAssignment({ + target: account as GoogleChatAccountLike, + path: `channels.googlechat.accounts.${accountId}`, + defaults: params.defaults, + context: params.context, + active: enabled, + inactiveReason: "Google Chat account is disabled.", + }); + } +} diff --git a/extensions/imessage/api.ts b/extensions/imessage/api.ts index 5fd4c0d5040..1eea0fcefa8 100644 --- a/extensions/imessage/api.ts +++ b/extensions/imessage/api.ts @@ -3,6 +3,7 @@ export * from "./src/accounts.js"; export * from "./src/conversation-bindings.js"; export * from "./src/conversation-id.js"; export * from "./src/group-policy.js"; +export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./src/normalize.js"; export { IMESSAGE_LEGACY_OUTBOUND_SEND_DEP_KEYS } from "./src/outbound-send-deps.js"; export * from "./src/probe.js"; export * from "./src/target-parsing-helpers.js"; diff --git a/extensions/imessage/contract-api.ts b/extensions/imessage/contract-api.ts new file mode 100644 index 00000000000..c962d22f63c --- /dev/null +++ b/extensions/imessage/contract-api.ts @@ -0,0 +1,10 @@ +export { createIMessageTestPlugin } from "./src/test-plugin.js"; +export { + resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots, + resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots, +} from "./src/media-contract.js"; +export { + DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, + resolveIMessageAttachmentRoots, + resolveIMessageRemoteAttachmentRoots, +} from "./src/media-contract.js"; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index fd177b260f5..b866d7645a7 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -135,6 +135,9 @@ export const imessagePlugin: ChannelPlugin @@ -195,7 +198,9 @@ export const imessagePlugin: ChannelPlugin - await (await loadIMessageChannelRuntime()).probeIMessageAccount({ + await ( + await loadIMessageChannelRuntime() + ).probeIMessageAccount({ timeoutMs, cliPath: account.config.cliPath, dbPath: account.config.dbPath, diff --git a/extensions/imessage/src/media-contract.ts b/extensions/imessage/src/media-contract.ts new file mode 100644 index 00000000000..1d8065d9a03 --- /dev/null +++ b/extensions/imessage/src/media-contract.ts @@ -0,0 +1,31 @@ +import { mergeInboundPathRoots } from "openclaw/plugin-sdk/media-runtime"; +import type { OpenClawConfig } from "../runtime-api.js"; +import { resolveIMessageAccount } from "./accounts.js"; + +export const DEFAULT_IMESSAGE_ATTACHMENT_ROOTS = ["/Users/*/Library/Messages/Attachments"] as const; + +export function resolveIMessageAttachmentRoots(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string[] { + const account = resolveIMessageAccount(params); + return mergeInboundPathRoots( + account.config.attachmentRoots, + params.cfg.channels?.imessage?.attachmentRoots, + DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, + ); +} + +export function resolveIMessageRemoteAttachmentRoots(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string[] { + const account = resolveIMessageAccount(params); + return mergeInboundPathRoots( + account.config.remoteAttachmentRoots, + params.cfg.channels?.imessage?.remoteAttachmentRoots, + account.config.attachmentRoots, + params.cfg.channels?.imessage?.attachmentRoots, + DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, + ); +} diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 62bf8550698..95fb5bff16e 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -20,12 +20,7 @@ import { import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime"; -import { - isInboundPathAllowed, - resolveIMessageAttachmentRoots, - resolveIMessageRemoteAttachmentRoots, -} from "openclaw/plugin-sdk/media-runtime"; -import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; +import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime"; import { clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, @@ -40,6 +35,10 @@ import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime"; import { resolveIMessageAccount } from "../accounts.js"; import { createIMessageRpcClient } from "../client.js"; import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js"; +import { + resolveIMessageAttachmentRoots, + resolveIMessageRemoteAttachmentRoots, +} from "../media-contract.js"; import { probeIMessage } from "../probe.js"; import { sendMessageIMessage } from "../send.js"; import { normalizeIMessageHandle } from "../targets.js"; diff --git a/extensions/imessage/src/test-plugin.ts b/extensions/imessage/src/test-plugin.ts new file mode 100644 index 00000000000..b9f0d8410a1 --- /dev/null +++ b/extensions/imessage/src/test-plugin.ts @@ -0,0 +1,111 @@ +import type { ChannelOutboundAdapter, ChannelPlugin } from "../../../src/channels/plugins/types.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +import { collectStatusIssuesFromLastError } from "../../../src/plugin-sdk/status-helpers.js"; + +function normalizeIMessageTestHandle(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + const lowered = trimmed.toLowerCase(); + if (lowered.startsWith("imessage:")) { + return normalizeIMessageTestHandle(trimmed.slice("imessage:".length)); + } + if (lowered.startsWith("sms:")) { + return normalizeIMessageTestHandle(trimmed.slice("sms:".length)); + } + if (lowered.startsWith("auto:")) { + return normalizeIMessageTestHandle(trimmed.slice("auto:".length)); + } + if (/^(chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) { + return trimmed.replace(/^(chat_id:|chat_guid:|chat_identifier:)/i, (match) => + match.toLowerCase(), + ); + } + if (trimmed.includes("@")) { + return trimmed.toLowerCase(); + } + const digits = trimmed.replace(/[^\d+]/g, ""); + if (digits) { + return digits.startsWith("+") ? `+${digits.slice(1)}` : `+${digits}`; + } + return trimmed.replace(/\s+/g, ""); +} + +const defaultIMessageOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + sendText: async ({ to, text, accountId, replyToId, deps, cfg }) => { + const sendIMessage = resolveOutboundSendDep< + ( + target: string, + content: string, + opts?: Record, + ) => Promise<{ messageId: string }> + >(deps, "imessage"); + const result = await sendIMessage?.(to, text, { + config: cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? undefined, + }); + return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, deps, cfg, mediaLocalRoots }) => { + const sendIMessage = resolveOutboundSendDep< + ( + target: string, + content: string, + opts?: Record, + ) => Promise<{ messageId: string }> + >(deps, "imessage"); + const result = await sendIMessage?.(to, text, { + config: cfg, + mediaUrl, + accountId: accountId ?? undefined, + replyToId: replyToId ?? undefined, + mediaLocalRoots, + }); + return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" }; + }, +}; + +export const createIMessageTestPlugin = (params?: { + outbound?: ChannelOutboundAdapter; +}): ChannelPlugin => ({ + id: "imessage", + meta: { + id: "imessage", + label: "iMessage", + selectionLabel: "iMessage (imsg)", + docsPath: "/channels/imessage", + blurb: "iMessage test stub.", + aliases: ["imsg"], + }, + capabilities: { chatTypes: ["direct", "group"], media: true }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + status: { + collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts), + }, + outbound: params?.outbound ?? defaultIMessageOutbound, + messaging: { + targetResolver: { + looksLikeId: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + if (/^(imessage:|sms:|auto:|chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) { + return true; + } + if (trimmed.includes("@")) { + return true; + } + return /^\+?\d{3,}$/.test(trimmed); + }, + hint: "", + }, + normalizeTarget: (raw) => normalizeIMessageTestHandle(raw), + }, +}); diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 08b18d0f771..a6122c17938 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -28,6 +28,7 @@ import { type ResolvedIrcAccount, } from "./accounts.js"; import { IrcChannelConfigSchema } from "./config-schema.js"; +import { collectIrcMutableAllowlistWarnings } from "./doctor.js"; import { monitorIrcProvider } from "./monitor.js"; import { normalizeIrcMessagingTarget, @@ -187,6 +188,10 @@ export const ircPlugin: ChannelPlugin = createChat }, }), }, + doctor: { + groupAllowFromFallbackToAllowFrom: false, + collectMutableAllowlistWarnings: collectIrcMutableAllowlistWarnings, + }, groups: { resolveRequireMention: ({ cfg, accountId, groupId }) => { const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); diff --git a/extensions/irc/src/doctor.ts b/extensions/irc/src/doctor.ts new file mode 100644 index 00000000000..69b200c2b41 --- /dev/null +++ b/extensions/irc/src/doctor.ts @@ -0,0 +1,53 @@ +import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; + +function asObjectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function isIrcMutableAllowEntry(raw: string): boolean { + const text = raw.trim().toLowerCase(); + if (!text || text === "*") { + return false; + } + + const normalized = text + .replace(/^irc:/, "") + .replace(/^user:/, "") + .trim(); + + return !normalized.includes("!") && !normalized.includes("@"); +} + +export const collectIrcMutableAllowlistWarnings = + createDangerousNameMatchingMutableAllowlistWarningCollector({ + channel: "irc", + detector: isIrcMutableAllowEntry, + collectLists: (scope) => { + const lists = [ + { + pathLabel: `${scope.prefix}.allowFrom`, + list: scope.account.allowFrom, + }, + { + pathLabel: `${scope.prefix}.groupAllowFrom`, + list: scope.account.groupAllowFrom, + }, + ]; + const groups = asObjectRecord(scope.account.groups); + if (groups) { + for (const [groupKey, groupRaw] of Object.entries(groups)) { + const group = asObjectRecord(groupRaw); + if (!group) { + continue; + } + lists.push({ + pathLabel: `${scope.prefix}.groups.${groupKey}.allowFrom`, + list: group.allowFrom, + }); + } + } + return lists; + }, + }); diff --git a/extensions/irc/src/runtime-api.ts b/extensions/irc/src/runtime-api.ts index 93f5c73f1cd..50769f59310 100644 --- a/extensions/irc/src/runtime-api.ts +++ b/extensions/irc/src/runtime-api.ts @@ -1,38 +1,51 @@ // Private runtime barrel for the bundled IRC extension. -// Keep this barrel thin and aligned with the local extension surface. +// Keep this barrel thin and generic-only. -export { - buildBaseChannelStatusSummary, - createAccountStatusSink, - chunkTextForOutbound, - createChannelPairingController, - DEFAULT_ACCOUNT_ID, - deliverFormattedTextWithAttachments, - dispatchInboundReplyWithBase, - getChatChannelMeta, - GROUP_POLICY_BLOCKED_LABEL, - isDangerousNameMatchingEnabled, - logInboundDrop, - PAIRING_APPROVED_MESSAGE, - readStoreAllowFromForDmPolicy, - resolveAllowlistProviderRuntimeGroupPolicy, - resolveControlCommandGate, - resolveDefaultGroupPolicy, - resolveEffectiveAllowFromLists, - warnMissingProviderGroupPolicyFallbackOnce, -} from "openclaw/plugin-sdk/irc"; export type { BaseProbeResult, - BlockStreamingCoalesceConfig, ChannelPlugin, + OpenClawConfig, + PluginRuntime, +} from "openclaw/plugin-sdk/core"; +export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +export type { + BlockStreamingCoalesceConfig, DmConfig, DmPolicy, GroupPolicy, GroupToolPolicyBySenderConfig, GroupToolPolicyConfig, MarkdownConfig, - OpenClawConfig, - OutboundReplyPayload, - PluginRuntime, - RuntimeEnv, -} from "openclaw/plugin-sdk/irc"; +} from "openclaw/plugin-sdk/config-runtime"; +export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload"; +export { + DEFAULT_ACCOUNT_ID, + buildChannelConfigSchema, + getChatChannelMeta, +} from "openclaw/plugin-sdk/core"; +export { + PAIRING_APPROVED_MESSAGE, + buildBaseChannelStatusSummary, +} from "openclaw/plugin-sdk/channel-status"; +export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; +export { createAccountStatusSink } from "openclaw/plugin-sdk/compat"; +export { + readStoreAllowFromForDmPolicy, + resolveEffectiveAllowFromLists, +} from "openclaw/plugin-sdk/channel-policy"; +export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; +export { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch"; +export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; +export { + deliverFormattedTextWithAttachments, + formatTextWithAttachmentLinks, + resolveOutboundMediaUrls, +} from "openclaw/plugin-sdk/reply-payload"; +export { + GROUP_POLICY_BLOCKED_LABEL, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, + isDangerousNameMatchingEnabled, +} from "openclaw/plugin-sdk/config-runtime"; +export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; diff --git a/extensions/line/api.ts b/extensions/line/api.ts index d398b092839..b42c8175ea0 100644 --- a/extensions/line/api.ts +++ b/extensions/line/api.ts @@ -1,12 +1,13 @@ export type { + ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig, OpenClawPluginApi, PluginRuntime, } from "openclaw/plugin-sdk/core"; +export type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract"; export { clearAccountEntryFields } from "openclaw/plugin-sdk/core"; export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; -export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/line"; export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract"; export { diff --git a/extensions/line/contract-api.ts b/extensions/line/contract-api.ts new file mode 100644 index 00000000000..c0d572e3b34 --- /dev/null +++ b/extensions/line/contract-api.ts @@ -0,0 +1,5 @@ +export { + listLineAccountIds, + resolveDefaultLineAccountId, + resolveLineAccount, +} from "./src/accounts.js"; diff --git a/extensions/line/runtime-api.ts b/extensions/line/runtime-api.ts index d44f5b5f2da..5061f8aeeea 100644 --- a/extensions/line/runtime-api.ts +++ b/extensions/line/runtime-api.ts @@ -2,16 +2,19 @@ // Keep this barrel thin and aligned with the local extension surface. export type { + ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig, OpenClawPluginApi, PluginRuntime, } from "openclaw/plugin-sdk/core"; +export type { + ChannelGatewayContext, + ChannelStatusIssue, +} from "openclaw/plugin-sdk/channel-contract"; export { clearAccountEntryFields } from "openclaw/plugin-sdk/core"; export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; -export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/line"; export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract"; export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; export { buildComputedAccountStatusSnapshot, @@ -46,6 +49,7 @@ export { sendMessageLine, } from "./src/send.js"; export { monitorLineProvider } from "./src/monitor.js"; +export { hasLineDirectives, parseLineDirectives } from "./src/reply-payload-transform.js"; export * from "./src/accounts.js"; export * from "./src/bot-access.js"; @@ -55,6 +59,7 @@ export * from "./src/download.js"; export * from "./src/group-keys.js"; export * from "./src/markdown-to-line.js"; export * from "./src/probe.js"; +export * from "./src/reply-payload-transform.js"; export * from "./src/send.js"; export * from "./src/signature.js"; export * from "./src/template-messages.js"; diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 203f16303d2..4a13c70da3c 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -8,6 +8,7 @@ import { lineChannelPluginCommon } from "./channel-shared.js"; import { lineGatewayAdapter } from "./gateway.js"; import { resolveLineGroupRequireMention } from "./group-policy.js"; import { lineOutboundAdapter } from "./outbound.js"; +import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js"; import { getLineRuntime } from "./runtime.js"; import { pushMessageLine } from "./send.js"; import { lineSetupAdapter } from "./setup-core.js"; @@ -74,6 +75,12 @@ export const linePlugin: ChannelPlugin = createChatChannelP }, resolveInboundConversation: ({ to, conversationId }) => resolveLineInboundConversation({ to, conversationId }), + transformReplyPayload: ({ payload }) => { + if (!payload.text || !hasLineDirectives(payload.text)) { + return payload; + } + return parseLineDirectives(payload); + }, targetResolver: { looksLikeId: (id) => { const trimmed = id?.trim(); diff --git a/extensions/line/src/reply-payload-transform.ts b/extensions/line/src/reply-payload-transform.ts new file mode 100644 index 00000000000..7375592cd3d --- /dev/null +++ b/extensions/line/src/reply-payload-transform.ts @@ -0,0 +1,317 @@ +import type { ReplyPayload } from "../runtime-api.js"; +import { + createAgendaCard, + createAppleTvRemoteCard, + createDeviceControlCard, + createEventCard, + createMediaPlayerCard, +} from "./flex-templates.js"; +import type { LineChannelData } from "./types.js"; + +/** + * Parse LINE-specific directives from text and extract them into ReplyPayload fields. + * + * Supported directives: + * - [[quick_replies: option1, option2, option3]] + * - [[location: title | address | latitude | longitude]] + * - [[confirm: question | yes_label | no_label]] + * - [[buttons: title | text | btn1:data1, btn2:data2]] + * - [[media_player: title | artist | source | imageUrl | playing/paused]] + * - [[event: title | date | time | location | description]] + * - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]] + * - [[device: name | type | status | ctrl1:data1, ctrl2:data2]] + * - [[appletv_remote: name | status]] + */ +export function parseLineDirectives(payload: ReplyPayload): ReplyPayload { + let text = payload.text; + if (!text) { + return payload; + } + + const result: ReplyPayload = { ...payload }; + const lineData: LineChannelData = { + ...(result.channelData?.line as LineChannelData | undefined), + }; + const toSlug = (value: string): string => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "device"; + const lineActionData = (action: string, extras?: Record): string => { + const base = [`line.action=${encodeURIComponent(action)}`]; + if (extras) { + for (const [key, value] of Object.entries(extras)) { + base.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + } + } + return base.join("&"); + }; + + const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i); + if (quickRepliesMatch) { + const options = quickRepliesMatch[1] + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (options.length > 0) { + lineData.quickReplies = [...(lineData.quickReplies || []), ...options]; + } + text = text.replace(quickRepliesMatch[0], "").trim(); + } + + const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i); + if (locationMatch && !lineData.location) { + const parts = locationMatch[1].split("|").map((s) => s.trim()); + if (parts.length >= 4) { + const [title, address, latStr, lonStr] = parts; + const latitude = parseFloat(latStr); + const longitude = parseFloat(lonStr); + if (!isNaN(latitude) && !isNaN(longitude)) { + lineData.location = { + title: title || "Location", + address: address || "", + latitude, + longitude, + }; + } + } + text = text.replace(locationMatch[0], "").trim(); + } + + const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i); + if (confirmMatch && !lineData.templateMessage) { + const parts = confirmMatch[1].split("|").map((s) => s.trim()); + if (parts.length >= 3) { + const [question, yesPart, noPart] = parts; + const [yesLabel, yesData] = yesPart.includes(":") + ? yesPart.split(":").map((s) => s.trim()) + : [yesPart, yesPart.toLowerCase()]; + const [noLabel, noData] = noPart.includes(":") + ? noPart.split(":").map((s) => s.trim()) + : [noPart, noPart.toLowerCase()]; + + lineData.templateMessage = { + type: "confirm", + text: question, + confirmLabel: yesLabel, + confirmData: yesData, + cancelLabel: noLabel, + cancelData: noData, + altText: question, + }; + } + text = text.replace(confirmMatch[0], "").trim(); + } + + const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i); + if (buttonsMatch && !lineData.templateMessage) { + const parts = buttonsMatch[1].split("|").map((s) => s.trim()); + if (parts.length >= 3) { + const [title, bodyText, actionsStr] = parts; + + const actions = actionsStr.split(",").map((actionStr) => { + const trimmed = actionStr.trim(); + const colonIndex = (() => { + const index = trimmed.indexOf(":"); + if (index === -1) { + return -1; + } + const lower = trimmed.toLowerCase(); + if (lower.startsWith("http://") || lower.startsWith("https://")) { + return -1; + } + return index; + })(); + + let label: string; + let data: string; + + if (colonIndex === -1) { + label = trimmed; + data = trimmed; + } else { + label = trimmed.slice(0, colonIndex).trim(); + data = trimmed.slice(colonIndex + 1).trim(); + } + + if (data.startsWith("http://") || data.startsWith("https://")) { + return { type: "uri" as const, label, uri: data }; + } + if (data.includes("=")) { + return { type: "postback" as const, label, data }; + } + return { type: "message" as const, label, data: data || label }; + }); + + if (actions.length > 0) { + lineData.templateMessage = { + type: "buttons", + title, + text: bodyText, + actions: actions.slice(0, 4), + altText: `${title}: ${bodyText}`, + }; + } + } + text = text.replace(buttonsMatch[0], "").trim(); + } + + const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i); + if (mediaPlayerMatch && !lineData.flexMessage) { + const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim()); + if (parts.length >= 1) { + const [title, artist, source, imageUrl, statusStr] = parts; + const isPlaying = statusStr?.toLowerCase() === "playing"; + const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined; + const deviceKey = toSlug(source || title || "media"); + const card = createMediaPlayerCard({ + title: title || "Unknown Track", + subtitle: artist || undefined, + source: source || undefined, + imageUrl: validImageUrl, + isPlaying: statusStr ? isPlaying : undefined, + controls: { + previous: { data: lineActionData("previous", { "line.device": deviceKey }) }, + play: { data: lineActionData("play", { "line.device": deviceKey }) }, + pause: { data: lineActionData("pause", { "line.device": deviceKey }) }, + next: { data: lineActionData("next", { "line.device": deviceKey }) }, + }, + }); + + lineData.flexMessage = { + altText: `🎵 ${title}${artist ? ` - ${artist}` : ""}`, + contents: card, + }; + } + text = text.replace(mediaPlayerMatch[0], "").trim(); + } + + const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i); + if (eventMatch && !lineData.flexMessage) { + const parts = eventMatch[1].split("|").map((s) => s.trim()); + if (parts.length >= 2) { + const [title, date, time, location, description] = parts; + + const card = createEventCard({ + title: title || "Event", + date: date || "TBD", + time: time || undefined, + location: location || undefined, + description: description || undefined, + }); + + lineData.flexMessage = { + altText: `📅 ${title} - ${date}${time ? ` ${time}` : ""}`, + contents: card, + }; + } + text = text.replace(eventMatch[0], "").trim(); + } + + const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i); + if (appleTvMatch && !lineData.flexMessage) { + const parts = appleTvMatch[1].split("|").map((s) => s.trim()); + if (parts.length >= 1) { + const [deviceName, status] = parts; + const deviceKey = toSlug(deviceName || "apple_tv"); + + const card = createAppleTvRemoteCard({ + deviceName: deviceName || "Apple TV", + status: status || undefined, + actionData: { + up: lineActionData("up", { "line.device": deviceKey }), + down: lineActionData("down", { "line.device": deviceKey }), + left: lineActionData("left", { "line.device": deviceKey }), + right: lineActionData("right", { "line.device": deviceKey }), + select: lineActionData("select", { "line.device": deviceKey }), + menu: lineActionData("menu", { "line.device": deviceKey }), + home: lineActionData("home", { "line.device": deviceKey }), + play: lineActionData("play", { "line.device": deviceKey }), + pause: lineActionData("pause", { "line.device": deviceKey }), + volumeUp: lineActionData("volume_up", { "line.device": deviceKey }), + volumeDown: lineActionData("volume_down", { "line.device": deviceKey }), + mute: lineActionData("mute", { "line.device": deviceKey }), + }, + }); + + lineData.flexMessage = { + altText: `📺 ${deviceName || "Apple TV"} Remote`, + contents: card, + }; + } + text = text.replace(appleTvMatch[0], "").trim(); + } + + const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i); + if (agendaMatch && !lineData.flexMessage) { + const parts = agendaMatch[1].split("|").map((s) => s.trim()); + if (parts.length >= 2) { + const [title, eventsStr] = parts; + const events = eventsStr.split(",").map((eventStr) => { + const trimmed = eventStr.trim(); + const colonIdx = trimmed.lastIndexOf(":"); + if (colonIdx > 0) { + return { + title: trimmed.slice(0, colonIdx).trim(), + time: trimmed.slice(colonIdx + 1).trim(), + }; + } + return { title: trimmed }; + }); + + const card = createAgendaCard({ + title: title || "Agenda", + events, + }); + + lineData.flexMessage = { + altText: `📋 ${title} (${events.length} events)`, + contents: card, + }; + } + text = text.replace(agendaMatch[0], "").trim(); + } + + const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i); + if (deviceMatch && !lineData.flexMessage) { + const parts = deviceMatch[1].split("|").map((s) => s.trim()); + if (parts.length >= 1) { + const [deviceName, deviceType, status, controlsStr] = parts; + const deviceKey = toSlug(deviceName || "device"); + const controls = controlsStr + ? controlsStr.split(",").map((ctrlStr) => { + const [label, data] = ctrlStr.split(":").map((s) => s.trim()); + const action = data || label.toLowerCase().replace(/\s+/g, "_"); + return { label, data: lineActionData(action, { "line.device": deviceKey }) }; + }) + : []; + + const card = createDeviceControlCard({ + deviceName: deviceName || "Device", + deviceType: deviceType || undefined, + status: status || undefined, + controls, + }); + + lineData.flexMessage = { + altText: `📱 ${deviceName}${status ? `: ${status}` : ""}`, + contents: card, + }; + } + text = text.replace(deviceMatch[0], "").trim(); + } + + text = text.replace(/\n{3,}/g, "\n\n").trim(); + + result.text = text || undefined; + if (Object.keys(lineData).length > 0) { + result.channelData = { ...result.channelData, line: lineData }; + } + return result; +} + +export function hasLineDirectives(text: string): boolean { + return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test( + text, + ); +} diff --git a/extensions/matrix/contract-api.ts b/extensions/matrix/contract-api.ts new file mode 100644 index 00000000000..ac88b533d0e --- /dev/null +++ b/extensions/matrix/contract-api.ts @@ -0,0 +1,16 @@ +export { + createMatrixThreadBindingManager, + resetMatrixThreadBindingsForTests, +} from "./src/matrix/thread-bindings.js"; +export { setMatrixRuntime } from "./src/runtime.js"; +export { + namedAccountPromotionKeys, + resolveSingleAccountPromotionTarget, + singleAccountKeysToMove, +} from "./src/setup-contract.js"; +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; +export { matrixSetupAdapter } from "./src/setup-core.js"; +export { matrixSetupWizard } from "./src/setup-surface.js"; diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts index d24bd6f9398..1ddd171f078 100644 --- a/extensions/matrix/runtime-api.ts +++ b/extensions/matrix/runtime-api.ts @@ -30,10 +30,10 @@ export type { OpenClawConfig, PluginRuntime, RuntimeLogger, - RuntimeEnv, WizardPrompter, -} from "openclaw/plugin-sdk/matrix-runtime-shared"; -export { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-runtime-shared"; +} from "openclaw/plugin-sdk/core"; +export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +export { formatZonedTimestamp } from "openclaw/plugin-sdk/core"; export function chunkTextForOutbound(text: string, limit: number): string[] { const chunks: string[] = []; diff --git a/extensions/matrix/runtime-heavy-api.ts b/extensions/matrix/runtime-heavy-api.ts new file mode 100644 index 00000000000..a0613c7fc4b --- /dev/null +++ b/extensions/matrix/runtime-heavy-api.ts @@ -0,0 +1 @@ +export * from "./src/runtime-heavy-api.js"; diff --git a/extensions/matrix/src/legacy-crypto.ts b/extensions/matrix/src/legacy-crypto.ts new file mode 100644 index 00000000000..71583ff4abc --- /dev/null +++ b/extensions/matrix/src/legacy-crypto.ts @@ -0,0 +1,529 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "openclaw/plugin-sdk/json-store"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; +import { resolveConfiguredMatrixAccountIds } from "./account-selection.js"; +import { + resolveLegacyMatrixFlatStoreTarget, + resolveMatrixMigrationAccountTarget, +} from "./migration-config.js"; +import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js"; + +const MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE = + "Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable."; + +type MatrixLegacyCryptoCounts = { + total: number; + backedUp: number; +}; + +type MatrixLegacyCryptoSummary = { + deviceId: string | null; + roomKeyCounts: MatrixLegacyCryptoCounts | null; + backupVersion: string | null; + decryptionKeyBase64: string | null; +}; + +type MatrixLegacyCryptoMigrationState = { + version: 1; + source: "matrix-bot-sdk-rust"; + accountId: string; + deviceId: string | null; + roomKeyCounts: MatrixLegacyCryptoCounts | null; + backupVersion: string | null; + decryptionKeyImported: boolean; + restoreStatus: "pending" | "completed" | "manual-action-required"; + detectedAt: string; + restoredAt?: string; + importedCount?: number; + totalCount?: number; + lastError?: string | null; +}; + +type MatrixLegacyCryptoPlan = { + accountId: string; + rootDir: string; + recoveryKeyPath: string; + statePath: string; + legacyCryptoPath: string; + homeserver: string; + userId: string; + accessToken: string; + deviceId: string | null; +}; + +type MatrixLegacyCryptoDetection = { + plans: MatrixLegacyCryptoPlan[]; + warnings: string[]; +}; + +type MatrixLegacyCryptoPreparationResult = { + migrated: boolean; + changes: string[]; + warnings: string[]; +}; + +type MatrixLegacyCryptoPrepareDeps = { + inspectLegacyStore: MatrixLegacyCryptoInspector; + writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl; +}; + +type MatrixLegacyCryptoInspectorParams = { + cryptoRootDir: string; + userId: string; + deviceId: string; + log?: (message: string) => void; +}; + +type MatrixLegacyCryptoInspectorResult = { + deviceId: string | null; + roomKeyCounts: { + total: number; + backedUp: number; + } | null; + backupVersion: string | null; + decryptionKeyBase64: string | null; +}; + +type MatrixLegacyCryptoInspector = ( + params: MatrixLegacyCryptoInspectorParams, +) => Promise; + +type MatrixLegacyBotSdkMetadata = { + deviceId: string | null; +}; + +type MatrixStoredRecoveryKey = { + version: 1; + createdAt: string; + keyId?: string | null; + encodedPrivateKey?: string; + privateKeyBase64: string; + keyInfo?: { + passphrase?: unknown; + name?: string; + }; +}; + +function isMatrixLegacyCryptoInspectorAvailable(): boolean { + return true; +} + +async function loadMatrixLegacyCryptoInspector(): Promise { + const module = await import("./matrix/legacy-crypto-inspector.js"); + return module.inspectLegacyMatrixCryptoStore as MatrixLegacyCryptoInspector; +} + +function detectLegacyBotSdkCryptoStore(cryptoRootDir: string): { + detected: boolean; + warning?: string; +} { + try { + const stat = fs.statSync(cryptoRootDir); + if (!stat.isDirectory()) { + return { + detected: false, + warning: + `Legacy Matrix encrypted state path exists but is not a directory: ${cryptoRootDir}. ` + + "OpenClaw skipped automatic crypto migration for that path.", + }; + } + } catch (err) { + return { + detected: false, + warning: + `Failed reading legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` + + "OpenClaw skipped automatic crypto migration for that path.", + }; + } + + try { + return { + detected: + fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) || + fs.existsSync(path.join(cryptoRootDir, "matrix-sdk-crypto.sqlite3")) || + fs + .readdirSync(cryptoRootDir, { withFileTypes: true }) + .some( + (entry) => + entry.isDirectory() && + fs.existsSync(path.join(cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")), + ), + }; + } catch (err) { + return { + detected: false, + warning: + `Failed scanning legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` + + "OpenClaw skipped automatic crypto migration for that path.", + }; + } +} + +function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] { + return resolveConfiguredMatrixAccountIds(cfg); +} + +function resolveLegacyMatrixFlatStorePlan(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): MatrixLegacyCryptoPlan | { warning: string } | null { + const legacy = resolveMatrixLegacyFlatStoragePaths(resolveStateDir(params.env, os.homedir)); + if (!fs.existsSync(legacy.cryptoPath)) { + return null; + } + const legacyStore = detectLegacyBotSdkCryptoStore(legacy.cryptoPath); + if (legacyStore.warning) { + return { warning: legacyStore.warning }; + } + if (!legacyStore.detected) { + return null; + } + + const target = resolveLegacyMatrixFlatStoreTarget({ + cfg: params.cfg, + env: params.env, + detectedPath: legacy.cryptoPath, + detectedKind: "encrypted state", + }); + if ("warning" in target) { + return target; + } + + const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath); + return { + accountId: target.accountId, + rootDir: target.rootDir, + recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"), + statePath: path.join(target.rootDir, "legacy-crypto-migration.json"), + legacyCryptoPath: legacy.cryptoPath, + homeserver: target.homeserver, + userId: target.userId, + accessToken: target.accessToken, + deviceId: metadata.deviceId ?? target.storedDeviceId, + }; +} + +function loadLegacyBotSdkMetadata(cryptoRootDir: string): MatrixLegacyBotSdkMetadata { + const metadataPath = path.join(cryptoRootDir, "bot-sdk.json"); + const fallback: MatrixLegacyBotSdkMetadata = { deviceId: null }; + try { + if (!fs.existsSync(metadataPath)) { + return fallback; + } + const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as { + deviceId?: unknown; + }; + return { + deviceId: + typeof parsed.deviceId === "string" && parsed.deviceId.trim() ? parsed.deviceId : null, + }; + } catch { + return fallback; + } +} + +function resolveMatrixLegacyCryptoPlans(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): MatrixLegacyCryptoDetection { + const warnings: string[] = []; + const plans: MatrixLegacyCryptoPlan[] = []; + + const flatPlan = resolveLegacyMatrixFlatStorePlan(params); + if (flatPlan) { + if ("warning" in flatPlan) { + warnings.push(flatPlan.warning); + } else { + plans.push(flatPlan); + } + } + + for (const accountId of resolveMatrixAccountIds(params.cfg)) { + const target = resolveMatrixMigrationAccountTarget({ + cfg: params.cfg, + env: params.env, + accountId, + }); + if (!target) { + continue; + } + const legacyCryptoPath = path.join(target.rootDir, "crypto"); + if (!fs.existsSync(legacyCryptoPath)) { + continue; + } + const detectedStore = detectLegacyBotSdkCryptoStore(legacyCryptoPath); + if (detectedStore.warning) { + warnings.push(detectedStore.warning); + continue; + } + if (!detectedStore.detected) { + continue; + } + if ( + plans.some( + (plan) => + plan.accountId === accountId && + path.resolve(plan.legacyCryptoPath) === path.resolve(legacyCryptoPath), + ) + ) { + continue; + } + const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath); + plans.push({ + accountId: target.accountId, + rootDir: target.rootDir, + recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"), + statePath: path.join(target.rootDir, "legacy-crypto-migration.json"), + legacyCryptoPath, + homeserver: target.homeserver, + userId: target.userId, + accessToken: target.accessToken, + deviceId: metadata.deviceId ?? target.storedDeviceId, + }); + } + + return { plans, warnings }; +} + +function loadStoredRecoveryKey(filePath: string): MatrixStoredRecoveryKey | null { + try { + if (!fs.existsSync(filePath)) { + return null; + } + return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixStoredRecoveryKey; + } catch { + return null; + } +} + +function loadLegacyCryptoMigrationState(filePath: string): MatrixLegacyCryptoMigrationState | null { + try { + if (!fs.existsSync(filePath)) { + return null; + } + return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixLegacyCryptoMigrationState; + } catch { + return null; + } +} + +async function persistLegacyMigrationState(params: { + filePath: string; + state: MatrixLegacyCryptoMigrationState; + writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl; +}): Promise { + await params.writeJsonFileAtomically(params.filePath, params.state); +} + +export function detectLegacyMatrixCrypto(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): MatrixLegacyCryptoDetection { + const detection = resolveMatrixLegacyCryptoPlans({ + cfg: params.cfg, + env: params.env ?? process.env, + }); + if (detection.plans.length > 0 && !isMatrixLegacyCryptoInspectorAvailable()) { + return { + plans: detection.plans, + warnings: [...detection.warnings, MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE], + }; + } + return detection; +} + +export async function autoPrepareLegacyMatrixCrypto(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + log?: { info?: (message: string) => void; warn?: (message: string) => void }; + deps?: Partial; +}): Promise { + const env = params.env ?? process.env; + const detection = params.deps?.inspectLegacyStore + ? resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env }) + : detectLegacyMatrixCrypto({ cfg: params.cfg, env }); + const warnings = [...detection.warnings]; + const changes: string[] = []; + const writeJsonFileAtomically = + params.deps?.writeJsonFileAtomically ?? writeJsonFileAtomicallyImpl; + if (detection.plans.length === 0) { + if (warnings.length > 0) { + params.log?.warn?.( + `matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`, + ); + } + return { + migrated: false, + changes, + warnings, + }; + } + + let inspectLegacyStore = params.deps?.inspectLegacyStore; + if (!inspectLegacyStore) { + try { + inspectLegacyStore = await loadMatrixLegacyCryptoInspector(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!warnings.includes(message)) { + warnings.push(message); + } + if (warnings.length > 0) { + params.log?.warn?.( + `matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`, + ); + } + return { + migrated: false, + changes, + warnings, + }; + } + } + if (!inspectLegacyStore) { + return { + migrated: false, + changes, + warnings, + }; + } + + for (const plan of detection.plans) { + const existingState = loadLegacyCryptoMigrationState(plan.statePath); + if (existingState?.version === 1) { + continue; + } + if (!plan.deviceId) { + warnings.push( + `Legacy Matrix encrypted state detected at ${plan.legacyCryptoPath}, but no device ID was found for account "${plan.accountId}". ` + + `OpenClaw will continue, but old encrypted history cannot be recovered automatically.`, + ); + continue; + } + + let summary: MatrixLegacyCryptoSummary; + try { + summary = await inspectLegacyStore({ + cryptoRootDir: plan.legacyCryptoPath, + userId: plan.userId, + deviceId: plan.deviceId, + log: params.log?.info, + }); + } catch (err) { + warnings.push( + `Failed inspecting legacy Matrix encrypted state for account "${plan.accountId}" (${plan.legacyCryptoPath}): ${String(err)}`, + ); + continue; + } + + let decryptionKeyImported = false; + if (summary.decryptionKeyBase64) { + const existingRecoveryKey = loadStoredRecoveryKey(plan.recoveryKeyPath); + if ( + existingRecoveryKey?.privateKeyBase64 && + existingRecoveryKey.privateKeyBase64 !== summary.decryptionKeyBase64 + ) { + warnings.push( + `Legacy Matrix backup key was found for account "${plan.accountId}", but ${plan.recoveryKeyPath} already contains a different recovery key. Leaving the existing file unchanged.`, + ); + } else if (!existingRecoveryKey?.privateKeyBase64) { + const payload: MatrixStoredRecoveryKey = { + version: 1, + createdAt: new Date().toISOString(), + keyId: null, + privateKeyBase64: summary.decryptionKeyBase64, + }; + try { + await writeJsonFileAtomically(plan.recoveryKeyPath, payload); + changes.push( + `Imported Matrix legacy backup key for account "${plan.accountId}": ${plan.recoveryKeyPath}`, + ); + decryptionKeyImported = true; + } catch (err) { + warnings.push( + `Failed writing Matrix recovery key for account "${plan.accountId}" (${plan.recoveryKeyPath}): ${String(err)}`, + ); + } + } else { + decryptionKeyImported = true; + } + } + + const localOnlyKeys = + summary.roomKeyCounts && summary.roomKeyCounts.total > summary.roomKeyCounts.backedUp + ? summary.roomKeyCounts.total - summary.roomKeyCounts.backedUp + : 0; + if (localOnlyKeys > 0) { + warnings.push( + `Legacy Matrix encrypted state for account "${plan.accountId}" contains ${localOnlyKeys} room key(s) that were never backed up. ` + + "Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.", + ); + } + if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.backedUp ?? 0) > 0) { + warnings.push( + `Legacy Matrix encrypted state for account "${plan.accountId}" has backed-up room keys, but no local backup decryption key was found. ` + + `Ask the operator to run "openclaw matrix verify backup restore --recovery-key " after upgrade if they have the recovery key.`, + ); + } + if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.total ?? 0) > 0) { + warnings.push( + `Legacy Matrix encrypted state for account "${plan.accountId}" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.`, + ); + } + // If recovery-key persistence failed, leave the migration state absent so the next startup can retry. + if ( + summary.decryptionKeyBase64 && + !decryptionKeyImported && + !loadStoredRecoveryKey(plan.recoveryKeyPath) + ) { + continue; + } + + const state: MatrixLegacyCryptoMigrationState = { + version: 1, + source: "matrix-bot-sdk-rust", + accountId: plan.accountId, + deviceId: summary.deviceId, + roomKeyCounts: summary.roomKeyCounts, + backupVersion: summary.backupVersion, + decryptionKeyImported, + restoreStatus: decryptionKeyImported ? "pending" : "manual-action-required", + detectedAt: new Date().toISOString(), + lastError: null, + }; + try { + await persistLegacyMigrationState({ + filePath: plan.statePath, + state, + writeJsonFileAtomically, + }); + changes.push( + `Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}": ${plan.statePath}`, + ); + } catch (err) { + warnings.push( + `Failed writing Matrix legacy encrypted-state migration record for account "${plan.accountId}" (${plan.statePath}): ${String(err)}`, + ); + } + } + + if (changes.length > 0) { + params.log?.info?.( + `matrix: prepared encrypted-state upgrade.\n${changes.map((entry) => `- ${entry}`).join("\n")}`, + ); + } + if (warnings.length > 0) { + params.log?.warn?.( + `matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`, + ); + } + + return { + migrated: changes.length > 0, + changes, + warnings, + }; +} diff --git a/extensions/matrix/src/legacy-state.ts b/extensions/matrix/src/legacy-state.ts new file mode 100644 index 00000000000..1368528a2e8 --- /dev/null +++ b/extensions/matrix/src/legacy-state.ts @@ -0,0 +1,156 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; +import { resolveLegacyMatrixFlatStoreTarget } from "./migration-config.js"; +import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js"; + +export type MatrixLegacyStateMigrationResult = { + migrated: boolean; + changes: string[]; + warnings: string[]; +}; + +type MatrixLegacyStatePlan = { + accountId: string; + legacyStoragePath: string; + legacyCryptoPath: string; + targetRootDir: string; + targetStoragePath: string; + targetCryptoPath: string; + selectionNote?: string; +}; + +function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): { + rootDir: string; + storagePath: string; + cryptoPath: string; +} { + const stateDir = resolveStateDir(env, os.homedir); + return resolveMatrixLegacyFlatStoragePaths(stateDir); +} + +function resolveMatrixMigrationPlan(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): MatrixLegacyStatePlan | { warning: string } | null { + const legacy = resolveLegacyMatrixPaths(params.env); + if (!fs.existsSync(legacy.storagePath) && !fs.existsSync(legacy.cryptoPath)) { + return null; + } + + const target = resolveLegacyMatrixFlatStoreTarget({ + cfg: params.cfg, + env: params.env, + detectedPath: legacy.rootDir, + detectedKind: "state", + }); + if ("warning" in target) { + return target; + } + + return { + accountId: target.accountId, + legacyStoragePath: legacy.storagePath, + legacyCryptoPath: legacy.cryptoPath, + targetRootDir: target.rootDir, + targetStoragePath: path.join(target.rootDir, "bot-storage.json"), + targetCryptoPath: path.join(target.rootDir, "crypto"), + selectionNote: target.selectionNote, + }; +} + +export function detectLegacyMatrixState(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): MatrixLegacyStatePlan | { warning: string } | null { + return resolveMatrixMigrationPlan({ + cfg: params.cfg, + env: params.env ?? process.env, + }); +} + +function moveLegacyPath(params: { + sourcePath: string; + targetPath: string; + label: string; + changes: string[]; + warnings: string[]; +}): void { + if (!fs.existsSync(params.sourcePath)) { + return; + } + if (fs.existsSync(params.targetPath)) { + params.warnings.push( + `Matrix legacy ${params.label} not migrated because the target already exists (${params.targetPath}).`, + ); + return; + } + try { + fs.mkdirSync(path.dirname(params.targetPath), { recursive: true }); + fs.renameSync(params.sourcePath, params.targetPath); + params.changes.push( + `Migrated Matrix legacy ${params.label}: ${params.sourcePath} -> ${params.targetPath}`, + ); + } catch (err) { + params.warnings.push( + `Failed migrating Matrix legacy ${params.label} (${params.sourcePath} -> ${params.targetPath}): ${String(err)}`, + ); + } +} + +export async function autoMigrateLegacyMatrixState(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + log?: { info?: (message: string) => void; warn?: (message: string) => void }; +}): Promise { + const env = params.env ?? process.env; + const detection = detectLegacyMatrixState({ cfg: params.cfg, env }); + if (!detection) { + return { migrated: false, changes: [], warnings: [] }; + } + if ("warning" in detection) { + params.log?.warn?.(`matrix: ${detection.warning}`); + return { migrated: false, changes: [], warnings: [detection.warning] }; + } + + const changes: string[] = []; + const warnings: string[] = []; + moveLegacyPath({ + sourcePath: detection.legacyStoragePath, + targetPath: detection.targetStoragePath, + label: "sync store", + changes, + warnings, + }); + moveLegacyPath({ + sourcePath: detection.legacyCryptoPath, + targetPath: detection.targetCryptoPath, + label: "crypto store", + changes, + warnings, + }); + + if (changes.length > 0) { + const details = [ + ...changes.map((entry) => `- ${entry}`), + ...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []), + "- No user action required.", + ]; + params.log?.info?.( + `matrix: plugin upgraded in place for account "${detection.accountId}".\n${details.join("\n")}`, + ); + } + if (warnings.length > 0) { + params.log?.warn?.( + `matrix: legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`, + ); + } + + return { + migrated: changes.length > 0, + changes, + warnings, + }; +} diff --git a/extensions/matrix/src/matrix-migration.runtime.ts b/extensions/matrix/src/matrix-migration.runtime.ts index d15f4456f7d..2f4dfd8062f 100644 --- a/extensions/matrix/src/matrix-migration.runtime.ts +++ b/extensions/matrix/src/matrix-migration.runtime.ts @@ -6,4 +6,4 @@ export { hasActionableMatrixMigration, hasPendingMatrixMigration, maybeCreateMatrixMigrationSnapshot, -} from "openclaw/plugin-sdk/matrix-runtime-heavy"; +} from "./runtime-heavy-api.js"; diff --git a/extensions/matrix/src/matrix/monitor/runtime-api.ts b/extensions/matrix/src/matrix/monitor/runtime-api.ts index 9dda853fbf2..4530d1044ff 100644 --- a/extensions/matrix/src/matrix/monitor/runtime-api.ts +++ b/extensions/matrix/src/matrix/monitor/runtime-api.ts @@ -2,30 +2,29 @@ // Keep monitor internals off the broad package runtime-api barrel so monitor // tests and shared workers do not pull unrelated Matrix helper surfaces. +export type { NormalizedLocation, PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/core"; +export type { BlockReplyContext, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +export type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +export { ensureConfiguredAcpBindingReady } from "openclaw/plugin-sdk/core"; export { addAllowlistUserEntriesFromConfigEntry, buildAllowlistResolutionSummary, - buildChannelKeyCandidates, canonicalizeAllowlistWithResolvedIds, - createReplyPrefixOptions, - createTypingCallbacks, formatAllowlistMatchMeta, - formatLocationText, - getAgentScopedMediaLocalRoots, - logInboundDrop, - logTypingFailure, patchAllowlistUsersInConfigEntries, - resolveAckReaction, - resolveChannelEntryMatch, summarizeMapping, +} from "openclaw/plugin-sdk/allow-from"; +export { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { createTypingCallbacks } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { + formatLocationText, + logInboundDrop, toLocationContext, - type BlockReplyContext, - type MarkdownTableMode, - type NormalizedLocation, - type OpenClawConfig, - type PluginRuntime, - type ReplyPayload, - type RuntimeEnv, - type RuntimeLogger, -} from "openclaw/plugin-sdk/matrix"; -export { ensureConfiguredAcpBindingReady } from "openclaw/plugin-sdk/matrix-runtime-heavy"; +} from "openclaw/plugin-sdk/channel-inbound"; +export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/agent-media-payload"; +export { logTypingFailure, resolveAckReaction } from "openclaw/plugin-sdk/channel-feedback"; +export { + buildChannelKeyCandidates, + resolveChannelEntryMatch, +} from "openclaw/plugin-sdk/channel-targets"; diff --git a/extensions/matrix/src/migration-config.ts b/extensions/matrix/src/migration-config.ts new file mode 100644 index 00000000000..f3f03732f45 --- /dev/null +++ b/extensions/matrix/src/migration-config.ts @@ -0,0 +1,326 @@ +import fs from "node:fs"; +import os from "node:os"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; +import { + findMatrixAccountEntry, + requiresExplicitMatrixDefaultAccount, + resolveConfiguredMatrixAccountIds, + resolveMatrixChannelConfig, + resolveMatrixDefaultOrOnlyAccountId, +} from "./account-selection.js"; +import { getMatrixScopedEnvVarNames } from "./env-vars.js"; +import { resolveMatrixAccountStorageRoot, resolveMatrixCredentialsPath } from "./storage-paths.js"; + +export type MatrixStoredCredentials = { + homeserver: string; + userId: string; + accessToken: string; + deviceId?: string; +}; + +export type MatrixMigrationAccountTarget = { + accountId: string; + homeserver: string; + userId: string; + accessToken: string; + rootDir: string; + storedDeviceId: string | null; +}; + +export type MatrixLegacyFlatStoreTarget = MatrixMigrationAccountTarget & { + selectionNote?: string; +}; + +type MatrixLegacyFlatStoreKind = "state" | "encrypted state"; + +type MatrixResolvedStringField = + | "homeserver" + | "userId" + | "accessToken" + | "password" + | "deviceId" + | "deviceName"; + +type MatrixResolvedStringValues = Record; + +type MatrixStringSourceMap = Partial>; + +const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set([ + "userId", + "accessToken", + "password", + "deviceId", +]); + +function clean(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function resolveMatrixStringSourceValue(value: string | undefined): string { + return typeof value === "string" ? value : ""; +} + +function shouldAllowBaseAuthFallback(accountId: string, field: MatrixResolvedStringField): boolean { + return ( + normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID || + !MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS.has(field) + ); +} + +function resolveMatrixAccountStringValues(params: { + accountId: string; + account?: MatrixStringSourceMap; + scopedEnv?: MatrixStringSourceMap; + channel?: MatrixStringSourceMap; + globalEnv?: MatrixStringSourceMap; +}): MatrixResolvedStringValues { + const fields: MatrixResolvedStringField[] = [ + "homeserver", + "userId", + "accessToken", + "password", + "deviceId", + "deviceName", + ]; + const resolved = {} as MatrixResolvedStringValues; + + for (const field of fields) { + resolved[field] = + resolveMatrixStringSourceValue(params.account?.[field]) || + resolveMatrixStringSourceValue(params.scopedEnv?.[field]) || + (shouldAllowBaseAuthFallback(params.accountId, field) + ? resolveMatrixStringSourceValue(params.channel?.[field]) || + resolveMatrixStringSourceValue(params.globalEnv?.[field]) + : ""); + } + + return resolved; +} + +function resolveScopedMatrixEnvConfig( + accountId: string, + env: NodeJS.ProcessEnv, +): { + homeserver: string; + userId: string; + accessToken: string; +} { + const keys = getMatrixScopedEnvVarNames(accountId); + return { + homeserver: clean(env[keys.homeserver]), + userId: clean(env[keys.userId]), + accessToken: clean(env[keys.accessToken]), + }; +} + +function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): { + homeserver: string; + userId: string; + accessToken: string; +} { + return { + homeserver: clean(env.MATRIX_HOMESERVER), + userId: clean(env.MATRIX_USER_ID), + accessToken: clean(env.MATRIX_ACCESS_TOKEN), + }; +} + +function resolveMatrixAccountConfigEntry( + cfg: OpenClawConfig, + accountId: string, +): Record | null { + return findMatrixAccountEntry(cfg, accountId); +} + +function resolveMatrixFlatStoreSelectionNote( + cfg: OpenClawConfig, + accountId: string, +): string | undefined { + if (resolveConfiguredMatrixAccountIds(cfg).length <= 1) { + return undefined; + } + return ( + `Legacy Matrix flat store uses one shared on-disk state, so it will be migrated into ` + + `account "${accountId}".` + ); +} + +export function resolveMatrixMigrationConfigFields(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + accountId: string; +}): { + homeserver: string; + userId: string; + accessToken: string; +} { + const channel = resolveMatrixChannelConfig(params.cfg); + const account = resolveMatrixAccountConfigEntry(params.cfg, params.accountId); + const scopedEnv = resolveScopedMatrixEnvConfig(params.accountId, params.env); + const globalEnv = resolveGlobalMatrixEnvConfig(params.env); + const normalizedAccountId = normalizeAccountId(params.accountId); + const resolvedStrings = resolveMatrixAccountStringValues({ + accountId: normalizedAccountId, + account: { + homeserver: clean(account?.homeserver), + userId: clean(account?.userId), + accessToken: clean(account?.accessToken), + }, + scopedEnv, + channel: { + homeserver: clean(channel?.homeserver), + userId: clean(channel?.userId), + accessToken: clean(channel?.accessToken), + }, + globalEnv, + }); + + return { + homeserver: resolvedStrings.homeserver, + userId: resolvedStrings.userId, + accessToken: resolvedStrings.accessToken, + }; +} + +export function loadStoredMatrixCredentials( + env: NodeJS.ProcessEnv, + accountId: string, +): MatrixStoredCredentials | null { + const stateDir = resolveStateDir(env, os.homedir); + const credentialsPath = resolveMatrixCredentialsPath({ + stateDir, + accountId: normalizeAccountId(accountId), + }); + try { + if (!fs.existsSync(credentialsPath)) { + return null; + } + const parsed = JSON.parse( + fs.readFileSync(credentialsPath, "utf8"), + ) as Partial; + if ( + typeof parsed.homeserver !== "string" || + typeof parsed.userId !== "string" || + typeof parsed.accessToken !== "string" + ) { + return null; + } + return { + homeserver: parsed.homeserver, + userId: parsed.userId, + accessToken: parsed.accessToken, + deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined, + }; + } catch { + return null; + } +} + +export function credentialsMatchResolvedIdentity( + stored: MatrixStoredCredentials | null, + identity: { + homeserver: string; + userId: string; + accessToken: string; + }, +): stored is MatrixStoredCredentials { + if (!stored || !identity.homeserver) { + return false; + } + if (!identity.userId) { + if (!identity.accessToken) { + return false; + } + return stored.homeserver === identity.homeserver && stored.accessToken === identity.accessToken; + } + return stored.homeserver === identity.homeserver && stored.userId === identity.userId; +} + +export function resolveMatrixMigrationAccountTarget(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + accountId: string; +}): MatrixMigrationAccountTarget | null { + const stored = loadStoredMatrixCredentials(params.env, params.accountId); + const resolved = resolveMatrixMigrationConfigFields(params); + const matchingStored = credentialsMatchResolvedIdentity(stored, { + homeserver: resolved.homeserver, + userId: resolved.userId, + accessToken: resolved.accessToken, + }) + ? stored + : null; + const homeserver = resolved.homeserver; + const userId = resolved.userId || matchingStored?.userId || ""; + const accessToken = resolved.accessToken || matchingStored?.accessToken || ""; + if (!homeserver || !userId || !accessToken) { + return null; + } + + const stateDir = resolveStateDir(params.env, os.homedir); + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver, + userId, + accessToken, + accountId: params.accountId, + }); + + return { + accountId: params.accountId, + homeserver, + userId, + accessToken, + rootDir, + storedDeviceId: matchingStored?.deviceId ?? null, + }; +} + +export function resolveLegacyMatrixFlatStoreTarget(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + detectedPath: string; + detectedKind: MatrixLegacyFlatStoreKind; +}): MatrixLegacyFlatStoreTarget | { warning: string } { + const channel = resolveMatrixChannelConfig(params.cfg); + if (!channel) { + return { + warning: + `Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but channels.matrix is not configured yet. ` + + 'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.', + }; + } + if (requiresExplicitMatrixDefaultAccount(params.cfg)) { + return { + warning: + `Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. ` + + 'Set "channels.matrix.defaultAccount" to the intended target account before rerunning "openclaw doctor --fix" or restarting the gateway.', + }; + } + + const accountId = resolveMatrixDefaultOrOnlyAccountId(params.cfg); + const target = resolveMatrixMigrationAccountTarget({ + cfg: params.cfg, + env: params.env, + accountId, + }); + if (!target) { + const targetDescription = + params.detectedKind === "state" + ? "the new account-scoped target" + : "the account-scoped target"; + return { + warning: + `Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but ${targetDescription} could not be resolved yet ` + + `(need homeserver, userId, and access token for channels.matrix${accountId === DEFAULT_ACCOUNT_ID ? "" : `.accounts.${accountId}`}). ` + + 'Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.', + }; + } + + return { + ...target, + selectionNote: resolveMatrixFlatStoreSelectionNote(params.cfg, accountId), + }; +} diff --git a/extensions/matrix/src/migration-snapshot.ts b/extensions/matrix/src/migration-snapshot.ts new file mode 100644 index 00000000000..941e1c09cfe --- /dev/null +++ b/extensions/matrix/src/migration-snapshot.ts @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store"; +import { resolveRequiredHomeDir } from "openclaw/plugin-sdk/provider-auth"; +import { createBackupArchive } from "openclaw/plugin-sdk/runtime"; +import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; +import { detectLegacyMatrixCrypto } from "./legacy-crypto.js"; +import { detectLegacyMatrixState } from "./legacy-state.js"; + +const MATRIX_MIGRATION_SNAPSHOT_DIRNAME = "openclaw-migrations"; + +function isMatrixLegacyCryptoInspectorAvailable(): boolean { + return true; +} + +type MatrixMigrationSnapshotMarker = { + version: 1; + createdAt: string; + archivePath: string; + trigger: string; + includeWorkspace: boolean; +}; + +export type MatrixMigrationSnapshotResult = { + created: boolean; + archivePath: string; + markerPath: string; +}; + +function loadSnapshotMarker(filePath: string): MatrixMigrationSnapshotMarker | null { + try { + if (!fs.existsSync(filePath)) { + return null; + } + const parsed = JSON.parse( + fs.readFileSync(filePath, "utf8"), + ) as Partial; + if ( + parsed.version !== 1 || + typeof parsed.createdAt !== "string" || + typeof parsed.archivePath !== "string" || + typeof parsed.trigger !== "string" + ) { + return null; + } + return { + version: 1, + createdAt: parsed.createdAt, + archivePath: parsed.archivePath, + trigger: parsed.trigger, + includeWorkspace: parsed.includeWorkspace === true, + }; + } catch { + return null; + } +} + +export function resolveMatrixMigrationSnapshotMarkerPath( + env: NodeJS.ProcessEnv = process.env, +): string { + const stateDir = resolveStateDir(env, os.homedir); + return path.join(stateDir, "matrix", "migration-snapshot.json"); +} + +export function resolveMatrixMigrationSnapshotOutputDir( + env: NodeJS.ProcessEnv = process.env, +): string { + const homeDir = resolveRequiredHomeDir(env, os.homedir); + return path.join(homeDir, "Backups", MATRIX_MIGRATION_SNAPSHOT_DIRNAME); +} + +export function hasPendingMatrixMigration(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): boolean { + const env = params.env ?? process.env; + const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env }); + if (legacyState) { + return true; + } + const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env }); + return legacyCrypto.plans.length > 0 || legacyCrypto.warnings.length > 0; +} + +export function hasActionableMatrixMigration(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): boolean { + const env = params.env ?? process.env; + const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env }); + if (legacyState && !("warning" in legacyState)) { + return true; + } + const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env }); + return legacyCrypto.plans.length > 0 && isMatrixLegacyCryptoInspectorAvailable(); +} + +export async function maybeCreateMatrixMigrationSnapshot(params: { + trigger: string; + env?: NodeJS.ProcessEnv; + outputDir?: string; + log?: { info?: (message: string) => void; warn?: (message: string) => void }; +}): Promise { + const env = params.env ?? process.env; + const markerPath = resolveMatrixMigrationSnapshotMarkerPath(env); + const existingMarker = loadSnapshotMarker(markerPath); + if (existingMarker?.archivePath && fs.existsSync(existingMarker.archivePath)) { + params.log?.info?.( + `matrix: reusing existing pre-migration backup snapshot: ${existingMarker.archivePath}`, + ); + return { + created: false, + archivePath: existingMarker.archivePath, + markerPath, + }; + } + if (existingMarker?.archivePath && !fs.existsSync(existingMarker.archivePath)) { + params.log?.warn?.( + `matrix: previous migration snapshot is missing (${existingMarker.archivePath}); creating a replacement backup before continuing`, + ); + } + + const snapshot = await createBackupArchive({ + output: (() => { + const outputDir = params.outputDir ?? resolveMatrixMigrationSnapshotOutputDir(env); + fs.mkdirSync(outputDir, { recursive: true }); + return outputDir; + })(), + includeWorkspace: false, + }); + + const marker: MatrixMigrationSnapshotMarker = { + version: 1, + createdAt: snapshot.createdAt, + archivePath: snapshot.archivePath, + trigger: params.trigger, + includeWorkspace: snapshot.includeWorkspace, + }; + await writeJsonFileAtomically(markerPath, marker); + params.log?.info?.(`matrix: created pre-migration backup snapshot: ${snapshot.archivePath}`); + return { + created: true, + archivePath: snapshot.archivePath, + markerPath, + }; +} diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index 6e004104686..66b8d41979d 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -1,36 +1,96 @@ export { DEFAULT_ACCOUNT_ID, - PAIRING_APPROVED_MESSAGE, buildChannelConfigSchema, - buildProbeChannelStatusSummary, - collectStatusIssuesFromLastError, createActionGate, - formatZonedTimestamp, getChatChannelMeta, jsonResult, - loadOutboundMediaFromUrl, normalizeAccountId, - normalizeOptionalAccountId, readNumberParam, readReactionParams, readStringArrayParam, readStringParam, -} from "openclaw/plugin-sdk/matrix"; -export * from "openclaw/plugin-sdk/matrix"; + type PollInput, + type ReplyPayload, +} from "openclaw/plugin-sdk/core"; +export type { + ChannelPlugin, + NormalizedLocation, + PluginRuntime, + RuntimeLogger, +} from "openclaw/plugin-sdk/core"; +export type { + BaseProbeResult, + ChannelDirectoryEntry, + ChannelGroupContext, + ChannelMessageActionAdapter, + ChannelMessageActionContext, + ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelOutboundAdapter, + ChannelResolveKind, + ChannelResolveResult, + ChannelToolSend, +} from "openclaw/plugin-sdk/channel-contract"; +export { formatZonedTimestamp } from "openclaw/plugin-sdk/core"; +export { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id"; +export type { ChannelSetupInput } from "openclaw/plugin-sdk/core"; +export type { + OpenClawConfig, + ContextVisibilityMode, + DmPolicy, + GroupPolicy, +} from "openclaw/plugin-sdk/config-runtime"; +export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-runtime"; +export type { WizardPrompter } from "openclaw/plugin-sdk/core"; +export type { SecretInput } from "openclaw/plugin-sdk/secret-input"; +export { + GROUP_POLICY_BLOCKED_LABEL, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "openclaw/plugin-sdk/config-runtime"; +export { + addWildcardAllowFrom, + formatDocsLink, + hasConfiguredSecretInput, + mergeAllowFromEntries, + moveSingleAccountChannelSectionToDefaultAccount, + promptAccountId, + promptChannelAccessConfig, +} from "openclaw/plugin-sdk/setup"; +export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; export { assertHttpUrlTargetsPrivateNetwork, closeDispatcher, createPinnedDispatcher, + isPrivateOrLoopbackHost, resolvePinnedHostnameWithPolicy, ssrfPolicyFromAllowPrivateNetwork, type LookupFn, type SsrFPolicy, } from "openclaw/plugin-sdk/ssrf-runtime"; +export { dispatchReplyFromConfigWithSettledDispatcher } from "openclaw/plugin-sdk/inbound-reply-dispatch"; export { - dispatchReplyFromConfigWithSettledDispatcher, ensureConfiguredAcpBindingReady, resolveConfiguredAcpBindingRecord, -} from "openclaw/plugin-sdk/matrix-runtime-heavy"; +} from "openclaw/plugin-sdk/core"; +export { + buildProbeChannelStatusSummary, + collectStatusIssuesFromLastError, + PAIRING_APPROVED_MESSAGE, +} from "openclaw/plugin-sdk/channel-status"; +export { + getSessionBindingService, + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingMaxAgeMsForChannel, +} from "openclaw/plugin-sdk/conversation-runtime"; +export { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime"; +export { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; +export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; +export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; +export { normalizePollInput } from "openclaw/plugin-sdk/media-runtime"; +export { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store"; // resolveMatrixAccountStringValues already comes from plugin-sdk/matrix. // Re-exporting auth-precedence here makes Jiti try to define the same export twice. diff --git a/extensions/matrix/src/runtime-heavy-api.ts b/extensions/matrix/src/runtime-heavy-api.ts new file mode 100644 index 00000000000..106a88f7c2e --- /dev/null +++ b/extensions/matrix/src/runtime-heavy-api.ts @@ -0,0 +1,7 @@ +export { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js"; +export { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js"; +export { + hasActionableMatrixMigration, + hasPendingMatrixMigration, + maybeCreateMatrixMigrationSnapshot, +} from "./migration-snapshot.js"; diff --git a/extensions/matrix/src/secret-contract.ts b/extensions/matrix/src/secret-contract.ts new file mode 100644 index 00000000000..4837d76dcff --- /dev/null +++ b/extensions/matrix/src/secret-contract.ts @@ -0,0 +1,169 @@ +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { + collectSecretInputAssignment, + getChannelSurface, + hasConfiguredSecretInputValue, + hasOwnProperty, + normalizeSecretStringValue, + type ResolverContext, + type SecretDefaults, + type SecretTargetRegistryEntry, +} from "openclaw/plugin-sdk/security-runtime"; +import { getMatrixScopedEnvVarNames } from "./env-vars.js"; + +export const secretTargetRegistryEntries = [ + { + id: "channels.matrix.accounts.*.accessToken", + targetType: "channels.matrix.accounts.*.accessToken", + configFile: "openclaw.json", + pathPattern: "channels.matrix.accounts.*.accessToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.matrix.accounts.*.password", + targetType: "channels.matrix.accounts.*.password", + configFile: "openclaw.json", + pathPattern: "channels.matrix.accounts.*.password", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.matrix.accessToken", + targetType: "channels.matrix.accessToken", + configFile: "openclaw.json", + pathPattern: "channels.matrix.accessToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.matrix.password", + targetType: "channels.matrix.password", + configFile: "openclaw.json", + pathPattern: "channels.matrix.password", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, +] satisfies SecretTargetRegistryEntry[]; + +export function collectRuntimeConfigAssignments(params: { + config: { channels?: Record }; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const resolved = getChannelSurface(params.config, "matrix"); + if (!resolved) { + return; + } + const { channel: matrix, surface } = resolved; + const envAccessTokenConfigured = + normalizeSecretStringValue(params.context.env.MATRIX_ACCESS_TOKEN).length > 0; + const defaultScopedAccessTokenConfigured = + normalizeSecretStringValue( + params.context.env[getMatrixScopedEnvVarNames("default").accessToken], + ).length > 0; + const defaultAccountAccessTokenConfigured = surface.accounts.some( + ({ accountId, account }) => + normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID && + hasConfiguredSecretInputValue(account.accessToken, params.defaults), + ); + const baseAccessTokenConfigured = hasConfiguredSecretInputValue( + matrix.accessToken, + params.defaults, + ); + collectSecretInputAssignment({ + value: matrix.accessToken, + path: "channels.matrix.accessToken", + expected: "string", + defaults: params.defaults, + context: params.context, + active: surface.channelEnabled, + inactiveReason: "Matrix channel is disabled.", + apply: (value) => { + matrix.accessToken = value; + }, + }); + collectSecretInputAssignment({ + value: matrix.password, + path: "channels.matrix.password", + expected: "string", + defaults: params.defaults, + context: params.context, + active: + surface.channelEnabled && + !( + baseAccessTokenConfigured || + envAccessTokenConfigured || + defaultScopedAccessTokenConfigured || + defaultAccountAccessTokenConfigured + ), + inactiveReason: + "Matrix channel is disabled or access-token auth is configured for the default Matrix account.", + apply: (value) => { + matrix.password = value; + }, + }); + if (!surface.hasExplicitAccounts) { + return; + } + for (const { accountId, account, enabled } of surface.accounts) { + if (hasOwnProperty(account, "accessToken")) { + collectSecretInputAssignment({ + value: account.accessToken, + path: `channels.matrix.accounts.${accountId}.accessToken`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: enabled, + inactiveReason: "Matrix account is disabled.", + apply: (value) => { + account.accessToken = value; + }, + }); + } + if (!hasOwnProperty(account, "password")) { + continue; + } + const accountAccessTokenConfigured = hasConfiguredSecretInputValue( + account.accessToken, + params.defaults, + ); + const scopedEnvAccessTokenConfigured = + normalizeSecretStringValue( + params.context.env[getMatrixScopedEnvVarNames(accountId).accessToken], + ).length > 0; + const inheritedDefaultAccountAccessTokenConfigured = + normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID && + (baseAccessTokenConfigured || envAccessTokenConfigured); + collectSecretInputAssignment({ + value: account.password, + path: `channels.matrix.accounts.${accountId}.password`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: + enabled && + !( + accountAccessTokenConfigured || + scopedEnvAccessTokenConfigured || + inheritedDefaultAccountAccessTokenConfigured + ), + inactiveReason: "Matrix account is disabled or this account has an accessToken configured.", + apply: (value) => { + account.password = value; + }, + }); + } +} diff --git a/extensions/matrix/src/setup-contract.ts b/extensions/matrix/src/setup-contract.ts new file mode 100644 index 00000000000..84cd969b4c8 --- /dev/null +++ b/extensions/matrix/src/setup-contract.ts @@ -0,0 +1,89 @@ +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/setup"; + +const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = [ + "deviceId", + "avatarUrl", + "initialSyncLimit", + "encryption", + "allowlistOnly", + "allowBots", + "blockStreaming", + "replyToMode", + "threadReplies", + "textChunkLimit", + "chunkMode", + "responsePrefix", + "ackReaction", + "ackReactionScope", + "reactionNotifications", + "threadBindings", + "startupVerification", + "startupVerificationCooldownHours", + "mediaMaxMb", + "autoJoin", + "autoJoinAllowlist", + "dm", + "groups", + "rooms", + "actions", +] as const; + +const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = [ + // When named accounts already exist, only move auth/bootstrap fields into the + // promoted account. Shared delivery-policy fields stay at the top level. + "name", + "homeserver", + "userId", + "accessToken", + "password", + "deviceId", + "deviceName", + "avatarUrl", + "initialSyncLimit", + "encryption", +] as const; + +export const singleAccountKeysToMove = [...MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE]; +export const namedAccountPromotionKeys = [...MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS]; + +export function resolveSingleAccountPromotionTarget(params: { + channel: Record; +}): string { + const accounts = + typeof params.channel.accounts === "object" && params.channel.accounts + ? (params.channel.accounts as Record) + : {}; + const normalizedDefaultAccount = + typeof params.channel.defaultAccount === "string" && params.channel.defaultAccount.trim() + ? normalizeAccountId(params.channel.defaultAccount) + : undefined; + if (normalizedDefaultAccount) { + if (normalizedDefaultAccount !== DEFAULT_ACCOUNT_ID) { + const matchedAccountId = Object.entries(accounts).find( + ([accountId, value]) => + accountId && + value && + typeof value === "object" && + normalizeAccountId(accountId) === normalizedDefaultAccount, + )?.[0]; + if (matchedAccountId) { + return matchedAccountId; + } + } + return DEFAULT_ACCOUNT_ID; + } + const namedAccounts = Object.entries(accounts).filter( + ([accountId, value]) => accountId && typeof value === "object" && value, + ); + if (namedAccounts.length === 1) { + return namedAccounts[0][0]; + } + if ( + namedAccounts.length > 1 && + accounts[DEFAULT_ACCOUNT_ID] && + typeof accounts[DEFAULT_ACCOUNT_ID] === "object" + ) { + return DEFAULT_ACCOUNT_ID; + } + return DEFAULT_ACCOUNT_ID; +} diff --git a/extensions/matrix/src/test-helpers.ts b/extensions/matrix/src/test-helpers.ts new file mode 100644 index 00000000000..1281c63ddd6 --- /dev/null +++ b/extensions/matrix/src/test-helpers.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import path from "node:path"; + +export const MATRIX_TEST_HOMESERVER = "https://matrix.example.org"; +export const MATRIX_DEFAULT_USER_ID = "@bot:example.org"; +export const MATRIX_DEFAULT_ACCESS_TOKEN = "tok-123"; +export const MATRIX_DEFAULT_DEVICE_ID = "DEVICE123"; +export const MATRIX_OPS_ACCOUNT_ID = "ops"; +export const MATRIX_OPS_USER_ID = "@ops-bot:example.org"; +export const MATRIX_OPS_ACCESS_TOKEN = "tok-ops"; +export const MATRIX_OPS_DEVICE_ID = "DEVICEOPS"; + +export function writeFile(filePath: string, value: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, value, "utf8"); +} + +export function writeMatrixCredentials( + stateDir: string, + params?: { + accountId?: string; + homeserver?: string; + userId?: string; + accessToken?: string; + deviceId?: string; + }, +) { + const accountId = params?.accountId ?? MATRIX_OPS_ACCOUNT_ID; + writeFile( + path.join(stateDir, "credentials", "matrix", `credentials-${accountId}.json`), + JSON.stringify( + { + homeserver: params?.homeserver ?? MATRIX_TEST_HOMESERVER, + userId: params?.userId ?? MATRIX_OPS_USER_ID, + accessToken: params?.accessToken ?? MATRIX_OPS_ACCESS_TOKEN, + deviceId: params?.deviceId ?? MATRIX_OPS_DEVICE_ID, + }, + null, + 2, + ), + ); +} diff --git a/extensions/mattermost/contract-api.ts b/extensions/mattermost/contract-api.ts new file mode 100644 index 00000000000..bc8f64f050f --- /dev/null +++ b/extensions/mattermost/contract-api.ts @@ -0,0 +1,4 @@ +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; diff --git a/extensions/mattermost/runtime-api.ts b/extensions/mattermost/runtime-api.ts index d4e591c8c1e..8168a2e00b2 100644 --- a/extensions/mattermost/runtime-api.ts +++ b/extensions/mattermost/runtime-api.ts @@ -1,4 +1,88 @@ // Private runtime barrel for the bundled Mattermost extension. -// Keep this barrel thin and aligned with the local extension surface. +// Keep this barrel thin and generic-only. -export * from "openclaw/plugin-sdk/mattermost"; +export type { + BaseProbeResult, + ChannelAccountSnapshot, + ChannelDirectoryEntry, + ChannelGroupContext, + ChannelMessageActionName, + ChannelPlugin, + ChatType, + HistoryEntry, + OpenClawConfig, + OpenClawPluginApi, + PluginRuntime, +} from "openclaw/plugin-sdk/core"; +export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +export type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth"; +export type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, +} from "openclaw/plugin-sdk/config-runtime"; +export { + DEFAULT_ACCOUNT_ID, + buildChannelConfigSchema, + createDedupeCache, + parseStrictPositiveInteger, + resolveClientIp, + isTrustedProxyAddress, +} from "openclaw/plugin-sdk/core"; +export { buildComputedAccountStatusSnapshot } from "openclaw/plugin-sdk/channel-status"; +export { createAccountStatusSink } from "openclaw/plugin-sdk/compat"; +export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload"; +export { + buildModelsProviderData, + listSkillCommandsForAgents, + resolveControlCommandGate, + resolveStoredModelOverride, +} from "openclaw/plugin-sdk/command-auth"; +export { + GROUP_POLICY_BLOCKED_LABEL, + isDangerousNameMatchingEnabled, + loadSessionStore, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + resolveStorePath, + warnMissingProviderGroupPolicyFallbackOnce, +} from "openclaw/plugin-sdk/config-runtime"; +export { formatInboundFromLabel } from "openclaw/plugin-sdk/channel-inbound"; +export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; +export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; +export { + DM_GROUP_ACCESS_REASON, + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithLists, + resolveEffectiveAllowFromLists, +} from "openclaw/plugin-sdk/channel-policy"; +export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; +export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; +export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; +export { rawDataToString } from "openclaw/plugin-sdk/browser-support"; +export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; +export { + DEFAULT_GROUP_HISTORY_LIMIT, + buildPendingHistoryContextFromMap, + clearHistoryEntriesIfEnabled, + recordPendingHistoryEntryIfEnabled, +} from "openclaw/plugin-sdk/reply-history"; +export { normalizeAccountId, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +export { resolveAllowlistMatchSimple } from "openclaw/plugin-sdk/allow-from"; +export { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-targets"; +export { + isRequestBodyLimitError, + readRequestBodyWithLimit, +} from "openclaw/plugin-sdk/webhook-ingress"; +export { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, +} from "openclaw/plugin-sdk/setup"; +export { + getAgentScopedMediaLocalRoots, + resolveChannelMediaMaxBytes, +} from "openclaw/plugin-sdk/media-runtime"; +export { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 490607a169b..f8682ecc66c 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -22,6 +22,7 @@ import { } from "openclaw/plugin-sdk/status-helpers"; import { mattermostApprovalAuth } from "./approval-auth.js"; import { MattermostChannelConfigSchema } from "./config-surface.js"; +import { collectMattermostMutableAllowlistWarnings } from "./doctor.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; import { listMattermostAccountIds, @@ -38,6 +39,7 @@ import { monitorMattermostProvider } from "./mattermost/monitor.js"; import { probeMattermost } from "./mattermost/probe.js"; import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js"; import { sendMessageMattermost } from "./mattermost/send.js"; +import { collectMattermostSlashCallbackPaths } from "./mattermost/slash-commands.js"; import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js"; import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; import { @@ -328,6 +330,9 @@ export const mattermostPlugin: ChannelPlugin = create }), }, auth: mattermostApprovalAuth, + doctor: { + collectMutableAllowlistWarnings: collectMattermostMutableAllowlistWarnings, + }, groups: { resolveRequireMention: resolveMattermostGroupRequireMention, }, @@ -401,6 +406,34 @@ export const mattermostPlugin: ChannelPlugin = create }), }), gateway: { + resolveGatewayAuthBypassPaths: ({ cfg }) => { + const base = cfg.channels?.mattermost; + const callbackPaths = new Set( + collectMattermostSlashCallbackPaths(base?.commands).filter( + (path) => + path === "/api/channels/mattermost/command" || + path.startsWith("/api/channels/mattermost/"), + ), + ); + const accounts = base?.accounts ?? {}; + for (const account of Object.values(accounts)) { + const accountConfig = + account && typeof account === "object" && !Array.isArray(account) + ? (account as { + commands?: Parameters[0]; + }) + : undefined; + for (const path of collectMattermostSlashCallbackPaths(accountConfig?.commands)) { + if ( + path === "/api/channels/mattermost/command" || + path.startsWith("/api/channels/mattermost/") + ) { + callbackPaths.add(path); + } + } + } + return [...callbackPaths]; + }, startAccount: async (ctx) => { const account = ctx.account; const statusSink = createAccountStatusSink({ diff --git a/extensions/mattermost/src/doctor.ts b/extensions/mattermost/src/doctor.ts new file mode 100644 index 00000000000..c679515735b --- /dev/null +++ b/extensions/mattermost/src/doctor.ts @@ -0,0 +1,36 @@ +import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; + +function isMattermostMutableAllowEntry(raw: string): boolean { + const text = raw.trim(); + if (!text || text === "*") { + return false; + } + + const normalized = text + .replace(/^(mattermost|user):/i, "") + .replace(/^@/, "") + .trim() + .toLowerCase(); + + if (/^[a-z0-9]{26}$/.test(normalized)) { + return false; + } + + return true; +} + +export const collectMattermostMutableAllowlistWarnings = + createDangerousNameMatchingMutableAllowlistWarningCollector({ + channel: "mattermost", + detector: isMattermostMutableAllowEntry, + collectLists: (scope) => [ + { + pathLabel: `${scope.prefix}.allowFrom`, + list: scope.account.allowFrom, + }, + { + pathLabel: `${scope.prefix}.groupAllowFrom`, + list: scope.account.groupAllowFrom, + }, + ], + }); diff --git a/extensions/mattermost/src/mattermost/slash-commands.ts b/extensions/mattermost/src/mattermost/slash-commands.ts index c7ddd80e7e2..5452540ee0a 100644 --- a/extensions/mattermost/src/mattermost/slash-commands.ts +++ b/extensions/mattermost/src/mattermost/slash-commands.ts @@ -534,6 +534,22 @@ export function isSlashCommandsEnabled(config: MattermostSlashCommandConfig): bo return false; } +export function collectMattermostSlashCallbackPaths(raw?: Partial) { + const config = resolveSlashCommandConfig(raw); + const paths = new Set([config.callbackPath]); + if (typeof config.callbackUrl === "string" && config.callbackUrl.trim()) { + try { + const pathname = new URL(config.callbackUrl).pathname; + if (pathname) { + paths.add(pathname); + } + } catch { + // Ignore invalid callback URLs and keep the normalized callback path only. + } + } + return [...paths]; +} + /** * Build the callback URL that Mattermost will POST to when a command is invoked. */ diff --git a/extensions/mattermost/src/secret-contract.ts b/extensions/mattermost/src/secret-contract.ts new file mode 100644 index 00000000000..6249e084dae --- /dev/null +++ b/extensions/mattermost/src/secret-contract.ts @@ -0,0 +1,54 @@ +import { + collectSimpleChannelFieldAssignments, + getChannelSurface, + type ResolverContext, + type SecretDefaults, + type SecretTargetRegistryEntry, +} from "openclaw/plugin-sdk/security-runtime"; + +export const secretTargetRegistryEntries = [ + { + id: "channels.mattermost.accounts.*.botToken", + targetType: "channels.mattermost.accounts.*.botToken", + configFile: "openclaw.json", + pathPattern: "channels.mattermost.accounts.*.botToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.mattermost.botToken", + targetType: "channels.mattermost.botToken", + configFile: "openclaw.json", + pathPattern: "channels.mattermost.botToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, +] satisfies SecretTargetRegistryEntry[]; + +export function collectRuntimeConfigAssignments(params: { + config: { channels?: Record }; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const resolved = getChannelSurface(params.config, "mattermost"); + if (!resolved) { + return; + } + const { channel: mattermost, surface } = resolved; + collectSimpleChannelFieldAssignments({ + channelKey: "mattermost", + field: "botToken", + channel: mattermost, + surface, + defaults: params.defaults, + context: params.context, + topInactiveReason: "no enabled account inherits this top-level Mattermost botToken.", + accountInactiveReason: "Mattermost account is disabled.", + }); +} diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 25e8e360431..b2bb7e7ed2d 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -30,6 +30,7 @@ import { } from "../runtime-api.js"; import { msTeamsApprovalAuth } from "./approval-auth.js"; import { MSTeamsChannelConfigSchema } from "./config-schema.js"; +import { collectMSTeamsMutableAllowlistWarnings } from "./doctor.js"; import { formatUnknownError } from "./errors.js"; import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; import type { ProbeMSTeamsResult } from "./probe.js"; @@ -388,6 +389,7 @@ export const msteamsPlugin: ChannelPlugin [ + { + pathLabel: `${scope.prefix}.allowFrom`, + list: scope.account.allowFrom, + }, + { + pathLabel: `${scope.prefix}.groupAllowFrom`, + list: scope.account.groupAllowFrom, + }, + ], + }); diff --git a/extensions/signal/api.ts b/extensions/signal/api.ts index aea9ce0ab9c..a1963c95fd0 100644 --- a/extensions/signal/api.ts +++ b/extensions/signal/api.ts @@ -1,8 +1,10 @@ export * from "./src/accounts.js"; export * from "./src/format.js"; export * from "./src/identity.js"; +export * from "./src/install-signal-cli.js"; export * from "./src/message-actions.js"; export * from "./src/monitor.js"; +export * from "./src/normalize.js"; export * from "./src/outbound-session.js"; export * from "./src/probe.js"; export * from "./src/reaction-level.js"; diff --git a/extensions/signal/contract-api.ts b/extensions/signal/contract-api.ts new file mode 100644 index 00000000000..cb72c981592 --- /dev/null +++ b/extensions/signal/contract-api.ts @@ -0,0 +1,3 @@ +export * from "./src/install-signal-cli.js"; +export * from "./src/normalize.js"; +export { isSignalSenderAllowed, type SignalSender } from "./src/identity.js"; diff --git a/extensions/signal/src/approval-auth.ts b/extensions/signal/src/approval-auth.ts index a8e5133af52..df942ecede4 100644 --- a/extensions/signal/src/approval-auth.ts +++ b/extensions/signal/src/approval-auth.ts @@ -2,9 +2,9 @@ import { createResolvedApproverActionAuthAdapter, resolveApprovalApprovers, } from "openclaw/plugin-sdk/approval-runtime"; -import { normalizeSignalMessagingTarget } from "openclaw/plugin-sdk/channel-targets"; import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import { resolveSignalAccount } from "./accounts.js"; +import { normalizeSignalMessagingTarget } from "./normalize.js"; import { looksLikeUuid } from "./uuid.js"; function normalizeSignalApproverId(value: string | number): string | undefined { diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 88c7d3728aa..714d82e1203 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -6,10 +6,6 @@ import { attachChannelToResults, } from "openclaw/plugin-sdk/channel-send-result"; import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status"; -import { - looksLikeSignalTargetId, - normalizeSignalMessagingTarget, -} from "openclaw/plugin-sdk/channel-targets"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/core"; import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime"; @@ -28,6 +24,7 @@ import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js" import { signalApprovalAuth } from "./approval-auth.js"; import { markdownToSignalTextChunks } from "./format.js"; import { signalMessageActions } from "./message-actions.js"; +import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js"; import { resolveSignalOutboundTarget } from "./outbound-session.js"; import { resolveSignalReactionLevel } from "./reaction-level.js"; import { signalSetupAdapter } from "./setup-core.js"; diff --git a/extensions/signal/src/install-signal-cli.ts b/extensions/signal/src/install-signal-cli.ts new file mode 100644 index 00000000000..bd79d40177a --- /dev/null +++ b/extensions/signal/src/install-signal-cli.ts @@ -0,0 +1,303 @@ +import { createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import { request } from "node:https"; +import os from "node:os"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/run-command"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { CONFIG_DIR, extractArchive, resolveBrewExecutable } from "openclaw/plugin-sdk/setup-tools"; + +export type ReleaseAsset = { + name?: string; + browser_download_url?: string; +}; + +export type NamedAsset = { + name: string; + browser_download_url: string; +}; + +type ReleaseResponse = { + tag_name?: string; + assets?: ReleaseAsset[]; +}; + +export type SignalInstallResult = { + ok: boolean; + cliPath?: string; + version?: string; + error?: string; +}; + +/** @internal Exported for testing. */ +export async function extractSignalCliArchive( + archivePath: string, + installRoot: string, + timeoutMs: number, +): Promise { + await extractArchive({ archivePath, destDir: installRoot, timeoutMs }); +} + +/** @internal Exported for testing. */ +export function looksLikeArchive(name: string): boolean { + return name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".zip"); +} + +/** + * Pick a native release asset from the official GitHub releases. + * + * The official signal-cli releases only publish native (GraalVM) binaries for + * x86-64 Linux. On architectures where no native asset is available this + * returns `undefined` so the caller can fall back to a different install + * strategy (e.g. Homebrew). + */ +/** @internal Exported for testing. */ +export function pickAsset( + assets: ReleaseAsset[], + platform: NodeJS.Platform, + arch: string, +): NamedAsset | undefined { + const withName = assets.filter((asset): asset is NamedAsset => + Boolean(asset.name && asset.browser_download_url), + ); + + // Archives only, excluding signature files (.asc) + const archives = withName.filter((a) => looksLikeArchive(a.name.toLowerCase())); + + const byName = (pattern: RegExp) => + archives.find((asset) => pattern.test(asset.name.toLowerCase())); + + if (platform === "linux") { + // The official "Linux-native" asset is an x86-64 GraalVM binary. + // On non-x64 architectures it will fail with "Exec format error", + // so only select it when the host architecture matches. + if (arch === "x64") { + return byName(/linux-native/) || byName(/linux/) || archives[0]; + } + // No native release for this arch — caller should fall back. + return undefined; + } + + if (platform === "darwin") { + return byName(/macos|osx|darwin/) || archives[0]; + } + + if (platform === "win32") { + return byName(/windows|win/) || archives[0]; + } + + return archives[0]; +} + +async function downloadToFile(url: string, dest: string, maxRedirects = 5): Promise { + await new Promise((resolve, reject) => { + const req = request(url, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) { + const location = res.headers.location; + if (!location || maxRedirects <= 0) { + reject(new Error("Redirect loop or missing Location header")); + return; + } + const redirectUrl = new URL(location, url).href; + resolve(downloadToFile(redirectUrl, dest, maxRedirects - 1)); + return; + } + if (!res.statusCode || res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading file`)); + return; + } + const out = createWriteStream(dest); + pipeline(res, out).then(resolve).catch(reject); + }); + req.on("error", reject); + req.end(); + }); +} + +async function findSignalCliBinary(root: string): Promise { + const candidates: string[] = []; + const enqueue = async (dir: string, depth: number) => { + if (depth > 3) { + return; + } + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + await enqueue(full, depth + 1); + } else if (entry.isFile() && entry.name === "signal-cli") { + candidates.push(full); + } + } + }; + await enqueue(root, 0); + return candidates[0] ?? null; +} + +// --------------------------------------------------------------------------- +// Brew-based install (used on architectures without an official native build) +// --------------------------------------------------------------------------- + +async function resolveBrewSignalCliPath(brewExe: string): Promise { + try { + const result = await runPluginCommandWithTimeout({ + argv: [brewExe, "--prefix", "signal-cli"], + timeoutMs: 10_000, + }); + if (result.code === 0 && result.stdout.trim()) { + const prefix = result.stdout.trim(); + // Homebrew installs the wrapper script at /bin/signal-cli + const candidate = path.join(prefix, "bin", "signal-cli"); + try { + await fs.access(candidate); + return candidate; + } catch { + // Fall back to searching the prefix + return findSignalCliBinary(prefix); + } + } + } catch { + // ignore + } + return null; +} + +async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise { + const brewExe = resolveBrewExecutable(); + if (!brewExe) { + return { + ok: false, + error: + `No native signal-cli build is available for ${process.arch}. ` + + "Install Homebrew (https://brew.sh) and try again, or install signal-cli manually.", + }; + } + + runtime.log(`Installing signal-cli via Homebrew (${brewExe})…`); + const result = await runPluginCommandWithTimeout({ + argv: [brewExe, "install", "signal-cli"], + timeoutMs: 15 * 60_000, // brew builds from source; can take a while + }); + + if (result.code !== 0) { + return { + ok: false, + error: `brew install signal-cli failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`, + }; + } + + const cliPath = await resolveBrewSignalCliPath(brewExe); + if (!cliPath) { + return { + ok: false, + error: "brew install succeeded but signal-cli binary was not found.", + }; + } + + // Extract version from the installed binary. + let version: string | undefined; + try { + const vResult = await runPluginCommandWithTimeout({ + argv: [cliPath, "--version"], + timeoutMs: 10_000, + }); + // Output is typically "signal-cli 0.13.24" + version = vResult.stdout.trim().replace(/^signal-cli\s+/, "") || undefined; + } catch { + // non-critical; leave version undefined + } + + return { ok: true, cliPath, version }; +} + +// --------------------------------------------------------------------------- +// Direct download install (used when an official native asset is available) +// --------------------------------------------------------------------------- + +async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise { + const apiUrl = "https://api.github.com/repos/AsamK/signal-cli/releases/latest"; + const response = await fetch(apiUrl, { + headers: { + "User-Agent": "openclaw", + Accept: "application/vnd.github+json", + }, + }); + + if (!response.ok) { + return { + ok: false, + error: `Failed to fetch release info (${response.status})`, + }; + } + + const payload = (await response.json()) as ReleaseResponse; + const version = payload.tag_name?.replace(/^v/, "") ?? "unknown"; + const assets = payload.assets ?? []; + const asset = pickAsset(assets, process.platform, process.arch); + + if (!asset) { + return { + ok: false, + error: "No compatible release asset found for this platform.", + }; + } + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-")); + const archivePath = path.join(tmpDir, asset.name); + + runtime.log(`Downloading signal-cli ${version} (${asset.name})…`); + await downloadToFile(asset.browser_download_url, archivePath); + + const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version); + await fs.mkdir(installRoot, { recursive: true }); + + if (!looksLikeArchive(asset.name.toLowerCase())) { + return { ok: false, error: `Unsupported archive type: ${asset.name}` }; + } + try { + await extractSignalCliArchive(archivePath, installRoot, 60_000); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + error: `Failed to extract ${asset.name}: ${message}`, + }; + } + + const cliPath = await findSignalCliBinary(installRoot); + if (!cliPath) { + return { + ok: false, + error: `signal-cli binary not found after extracting ${asset.name}`, + }; + } + + await fs.chmod(cliPath, 0o755).catch(() => {}); + + return { ok: true, cliPath, version }; +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +export async function installSignalCli(runtime: RuntimeEnv): Promise { + if (process.platform === "win32") { + return { + ok: false, + error: "Signal CLI auto-install is not supported on Windows yet.", + }; + } + + // The official signal-cli GitHub releases only ship a native binary for + // x86-64 Linux. On other architectures (arm64, armv7, etc.) we delegate + // to Homebrew which builds from source and bundles the JRE automatically. + const hasNativeRelease = process.platform !== "linux" || process.arch === "x64"; + + if (hasNativeRelease) { + return installSignalCliFromRelease(runtime); + } + + return installSignalCliViaBrew(runtime); +} diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index db030e4156f..612c4896fd3 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -14,7 +14,6 @@ import { resolveMentionGatingWithBypass, } from "openclaw/plugin-sdk/channel-inbound"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { normalizeSignalMessagingTarget } from "openclaw/plugin-sdk/channel-targets"; import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; import { @@ -57,6 +56,7 @@ import { resolveSignalSender, type SignalSender, } from "../identity.js"; +import { normalizeSignalMessagingTarget } from "../normalize.js"; import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js"; import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js"; import type { diff --git a/extensions/signal/src/runtime-api.ts b/extensions/signal/src/runtime-api.ts index 90dd7e4c5a6..0f56c4e3807 100644 --- a/extensions/signal/src/runtime-api.ts +++ b/extensions/signal/src/runtime-api.ts @@ -23,7 +23,7 @@ export { export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime"; export { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; export { chunkText } from "openclaw/plugin-sdk/reply-runtime"; -export { detectBinary, installSignalCli } from "openclaw/plugin-sdk/setup-tools"; +export { detectBinary } from "openclaw/plugin-sdk/setup-tools"; export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, @@ -35,10 +35,7 @@ export { createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; export { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; -export { - looksLikeSignalTargetId, - normalizeSignalMessagingTarget, -} from "openclaw/plugin-sdk/channel-targets"; +export { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js"; export { listEnabledSignalAccounts, listSignalAccountIds, @@ -46,6 +43,7 @@ export { resolveSignalAccount, } from "./accounts.js"; export { monitorSignalProvider } from "./monitor.js"; +export { installSignalCli } from "./install-signal-cli.js"; export { probeSignal } from "./probe.js"; export { resolveSignalReactionLevel } from "./reaction-level.js"; export { removeReactionSignal, sendReactionSignal } from "./send-reactions.js"; diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index a9d11ff4ac0..e7485009026 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -3,8 +3,9 @@ import { setSetupChannelEnabled, type ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; -import { detectBinary, installSignalCli } from "openclaw/plugin-sdk/setup-tools"; +import { detectBinary } from "openclaw/plugin-sdk/setup-tools"; import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js"; +import { installSignalCli } from "./install-signal-cli.js"; import { createSignalCliPathTextInput, normalizeSignalAccountInput, @@ -28,7 +29,13 @@ export const signalSetupWizard: ChannelSetupWizard = { unconfiguredHint: "signal-cli missing", configuredScore: 1, unconfiguredScore: 0, - resolveConfigured: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }).configured, + resolveConfigured: ({ cfg, accountId }) => + accountId + ? resolveSignalAccount({ cfg, accountId }).configured + : listSignalAccountIds(cfg).some( + (resolvedAccountId) => + resolveSignalAccount({ cfg, accountId: resolvedAccountId }).configured, + ), resolveBinaryPath: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }).config.cliPath ?? "signal-cli", detectBinary, diff --git a/extensions/slack/contract-api.ts b/extensions/slack/contract-api.ts new file mode 100644 index 00000000000..ef0d709d573 --- /dev/null +++ b/extensions/slack/contract-api.ts @@ -0,0 +1,11 @@ +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; +export { createSlackOutboundPayloadHarness } from "./src/outbound-payload-harness.js"; +export type { + SlackInteractiveHandlerContext, + SlackInteractiveHandlerRegistration, +} from "./src/interactive-dispatch.js"; +export { collectSlackSecurityAuditFindings } from "./src/security-audit.js"; diff --git a/extensions/slack/src/doctor-contract.ts b/extensions/slack/src/doctor-contract.ts new file mode 100644 index 00000000000..1e201c7a4db --- /dev/null +++ b/extensions/slack/src/doctor-contract.ts @@ -0,0 +1,167 @@ +import type { + ChannelDoctorConfigMutation, + ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + formatSlackStreamingBooleanMigrationMessage, + formatSlackStreamModeMigrationMessage, + resolveSlackNativeStreaming, + resolveSlackStreamingMode, +} from "./streaming-compat.js"; + +function asObjectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function normalizeSlackStreamingAliases(params: { + entry: Record; + pathPrefix: string; + changes: string[]; +}): { entry: Record; changed: boolean } { + let updated = params.entry; + const hadLegacyStreamMode = updated.streamMode !== undefined; + const legacyStreaming = updated.streaming; + const beforeStreaming = updated.streaming; + const beforeNativeStreaming = updated.nativeStreaming; + const resolvedStreaming = resolveSlackStreamingMode(updated); + const resolvedNativeStreaming = resolveSlackNativeStreaming(updated); + const shouldNormalize = + hadLegacyStreamMode || + typeof legacyStreaming === "boolean" || + (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming); + if (!shouldNormalize) { + return { entry: updated, changed: false }; + } + + let changed = false; + if (beforeStreaming !== resolvedStreaming) { + updated = { ...updated, streaming: resolvedStreaming }; + changed = true; + } + if ( + typeof beforeNativeStreaming !== "boolean" || + beforeNativeStreaming !== resolvedNativeStreaming + ) { + updated = { ...updated, nativeStreaming: resolvedNativeStreaming }; + changed = true; + } + if (hadLegacyStreamMode) { + const { streamMode: _ignored, ...rest } = updated; + updated = rest; + changed = true; + params.changes.push( + formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming), + ); + } + if (typeof legacyStreaming === "boolean") { + params.changes.push( + formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming), + ); + } else if (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming) { + params.changes.push( + `Normalized ${params.pathPrefix}.streaming (${legacyStreaming}) → (${resolvedStreaming}).`, + ); + } + + return { entry: updated, changed }; +} + +function hasLegacySlackStreamingAliases(value: unknown): boolean { + const entry = asObjectRecord(value); + if (!entry) { + return false; + } + return ( + entry.streamMode !== undefined || + typeof entry.streaming === "boolean" || + (typeof entry.streaming === "string" && entry.streaming !== resolveSlackStreamingMode(entry)) + ); +} + +function hasLegacySlackAccountStreamingAliases(value: unknown): boolean { + const accounts = asObjectRecord(value); + if (!accounts) { + return false; + } + return Object.values(accounts).some((account) => hasLegacySlackStreamingAliases(account)); +} + +export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "slack"], + message: + "channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming and channels.slack.nativeStreaming.", + match: hasLegacySlackStreamingAliases, + }, + { + path: ["channels", "slack", "accounts"], + message: + "channels.slack.accounts..streamMode and boolean channels.slack.accounts..streaming are legacy; use channels.slack.accounts..streaming and channels.slack.accounts..nativeStreaming.", + match: hasLegacySlackAccountStreamingAliases, + }, +]; + +export function normalizeCompatibilityConfig({ + cfg, +}: { + cfg: OpenClawConfig; +}): ChannelDoctorConfigMutation { + const rawEntry = asObjectRecord((cfg.channels as Record | undefined)?.slack); + if (!rawEntry) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updated = rawEntry; + let changed = false; + + const baseStreaming = normalizeSlackStreamingAliases({ + entry: updated, + pathPrefix: "channels.slack", + changes, + }); + updated = baseStreaming.entry; + changed = changed || baseStreaming.changed; + + const rawAccounts = asObjectRecord(updated.accounts); + if (rawAccounts) { + let accountsChanged = false; + const accounts = { ...rawAccounts }; + for (const [accountId, rawAccount] of Object.entries(rawAccounts)) { + const account = asObjectRecord(rawAccount); + if (!account) { + continue; + } + const streaming = normalizeSlackStreamingAliases({ + entry: account, + pathPrefix: `channels.slack.accounts.${accountId}`, + changes, + }); + if (streaming.changed) { + accounts[accountId] = streaming.entry; + accountsChanged = true; + } + } + if (accountsChanged) { + updated = { ...updated, accounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + slack: updated as unknown as NonNullable["slack"], + } as OpenClawConfig["channels"], + }, + changes, + }; +} diff --git a/extensions/slack/src/doctor.ts b/extensions/slack/src/doctor.ts index fd917df40b5..a1465d75b11 100644 --- a/extensions/slack/src/doctor.ts +++ b/extensions/slack/src/doctor.ts @@ -1,18 +1,17 @@ import { type ChannelDoctorAdapter, type ChannelDoctorConfigMutation, + type ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; +import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime"; +import { isSlackMutableAllowEntry } from "./security-doctor.js"; import { formatSlackStreamingBooleanMigrationMessage, formatSlackStreamModeMigrationMessage, resolveSlackNativeStreaming, resolveSlackStreamingMode, - type OpenClawConfig, -} from "openclaw/plugin-sdk/config-runtime"; -import { - collectProviderDangerousNameMatchingScopes, - isSlackMutableAllowEntry, -} from "openclaw/plugin-sdk/runtime"; +} from "./streaming-compat.js"; function asObjectRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) @@ -294,11 +293,47 @@ export function collectSlackMutableAllowlistWarnings(cfg: OpenClawConfig): strin ]; } +function hasLegacySlackStreamingAliases(value: unknown): boolean { + const entry = asObjectRecord(value); + if (!entry) { + return false; + } + return ( + entry.streamMode !== undefined || + typeof entry.streaming === "boolean" || + (typeof entry.streaming === "string" && entry.streaming !== resolveSlackStreamingMode(entry)) + ); +} + +function hasLegacySlackAccountStreamingAliases(value: unknown): boolean { + const accounts = asObjectRecord(value); + if (!accounts) { + return false; + } + return Object.values(accounts).some((account) => hasLegacySlackStreamingAliases(account)); +} + +const SLACK_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "slack"], + message: + "channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming and channels.slack.nativeStreaming.", + match: hasLegacySlackStreamingAliases, + }, + { + path: ["channels", "slack", "accounts"], + message: + "channels.slack.accounts..streamMode and boolean channels.slack.accounts..streaming are legacy; use channels.slack.accounts..streaming and channels.slack.accounts..nativeStreaming.", + match: hasLegacySlackAccountStreamingAliases, + }, +]; + export const slackDoctor: ChannelDoctorAdapter = { dmAllowFromMode: "topOrNested", groupModel: "route", groupAllowFromFallbackToAllowFrom: false, warnOnEmptyGroupSenderAllowlist: false, + legacyConfigRules: SLACK_LEGACY_CONFIG_RULES, normalizeCompatibilityConfig: ({ cfg }) => normalizeSlackCompatibilityConfig(cfg), collectMutableAllowlistWarnings: ({ cfg }) => collectSlackMutableAllowlistWarnings(cfg), }; diff --git a/extensions/slack/src/outbound-payload-harness.ts b/extensions/slack/src/outbound-payload-harness.ts new file mode 100644 index 00000000000..2b63383a1d0 --- /dev/null +++ b/extensions/slack/src/outbound-payload-harness.ts @@ -0,0 +1,50 @@ +import { vi, type Mock } from "vitest"; +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; +import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/test-helpers.js"; +import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; +import { loadBundledPluginTestApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; + +type OutboundSendMock = Mock<(...args: unknown[]) => Promise>>; + +type SlackOutboundPayloadHarness = { + run: () => Promise>; + sendMock: OutboundSendMock; + to: string; +}; + +let slackOutboundCache: ChannelOutboundAdapter | undefined; + +function getSlackOutbound(): ChannelOutboundAdapter { + if (!slackOutboundCache) { + ({ slackOutbound: slackOutboundCache } = loadBundledPluginTestApiSync<{ + slackOutbound: ChannelOutboundAdapter; + }>("slack")); + } + return slackOutboundCache; +} + +export function createSlackOutboundPayloadHarness(params: { + payload: ReplyPayload; + sendResults?: Array<{ messageId: string }>; +}): SlackOutboundPayloadHarness { + const sendSlack: OutboundSendMock = vi.fn(); + primeChannelOutboundSendMock( + sendSlack, + { messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }, + params.sendResults, + ); + const ctx = { + cfg: {}, + to: "C12345", + text: "", + payload: params.payload, + deps: { + sendSlack, + }, + }; + return { + run: async () => await getSlackOutbound().sendPayload!(ctx), + sendMock: sendSlack, + to: ctx.to, + }; +} diff --git a/extensions/slack/src/secret-contract.ts b/extensions/slack/src/secret-contract.ts new file mode 100644 index 00000000000..13fdbbe4ae4 --- /dev/null +++ b/extensions/slack/src/secret-contract.ts @@ -0,0 +1,158 @@ +import { + collectConditionalChannelFieldAssignments, + collectSimpleChannelFieldAssignments, + getChannelSurface, + hasOwnProperty, + type ResolverContext, + type SecretDefaults, + type SecretTargetRegistryEntry, +} from "openclaw/plugin-sdk/security-runtime"; + +export const secretTargetRegistryEntries = [ + { + id: "channels.slack.accounts.*.appToken", + targetType: "channels.slack.accounts.*.appToken", + configFile: "openclaw.json", + pathPattern: "channels.slack.accounts.*.appToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.slack.accounts.*.botToken", + targetType: "channels.slack.accounts.*.botToken", + configFile: "openclaw.json", + pathPattern: "channels.slack.accounts.*.botToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.slack.accounts.*.signingSecret", + targetType: "channels.slack.accounts.*.signingSecret", + configFile: "openclaw.json", + pathPattern: "channels.slack.accounts.*.signingSecret", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.slack.accounts.*.userToken", + targetType: "channels.slack.accounts.*.userToken", + configFile: "openclaw.json", + pathPattern: "channels.slack.accounts.*.userToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.slack.appToken", + targetType: "channels.slack.appToken", + configFile: "openclaw.json", + pathPattern: "channels.slack.appToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.slack.botToken", + targetType: "channels.slack.botToken", + configFile: "openclaw.json", + pathPattern: "channels.slack.botToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.slack.signingSecret", + targetType: "channels.slack.signingSecret", + configFile: "openclaw.json", + pathPattern: "channels.slack.signingSecret", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.slack.userToken", + targetType: "channels.slack.userToken", + configFile: "openclaw.json", + pathPattern: "channels.slack.userToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, +] satisfies SecretTargetRegistryEntry[]; + +export function collectRuntimeConfigAssignments(params: { + config: { channels?: Record }; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const resolved = getChannelSurface(params.config, "slack"); + if (!resolved) { + return; + } + const { channel: slack, surface } = resolved; + const baseMode = slack.mode === "http" || slack.mode === "socket" ? slack.mode : "socket"; + const fields = ["botToken", "userToken"] as const; + for (const field of fields) { + collectSimpleChannelFieldAssignments({ + channelKey: "slack", + field, + channel: slack, + surface, + defaults: params.defaults, + context: params.context, + topInactiveReason: `no enabled account inherits this top-level Slack ${field}.`, + accountInactiveReason: "Slack account is disabled.", + }); + } + const resolveAccountMode = (account: Record) => + account.mode === "http" || account.mode === "socket" ? account.mode : baseMode; + collectConditionalChannelFieldAssignments({ + channelKey: "slack", + field: "appToken", + channel: slack, + surface, + defaults: params.defaults, + context: params.context, + topLevelActiveWithoutAccounts: baseMode !== "http", + topLevelInheritedAccountActive: ({ account, enabled }) => + enabled && !hasOwnProperty(account, "appToken") && resolveAccountMode(account) !== "http", + accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) !== "http", + topInactiveReason: "no enabled Slack socket-mode surface inherits this top-level appToken.", + accountInactiveReason: "Slack account is disabled or not running in socket mode.", + }); + collectConditionalChannelFieldAssignments({ + channelKey: "slack", + field: "signingSecret", + channel: slack, + surface, + defaults: params.defaults, + context: params.context, + topLevelActiveWithoutAccounts: baseMode === "http", + topLevelInheritedAccountActive: ({ account, enabled }) => + enabled && + !hasOwnProperty(account, "signingSecret") && + resolveAccountMode(account) === "http", + accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "http", + topInactiveReason: "no enabled Slack HTTP-mode surface inherits this top-level signingSecret.", + accountInactiveReason: "Slack account is disabled or not running in HTTP mode.", + }); +} diff --git a/extensions/slack/src/security-doctor.ts b/extensions/slack/src/security-doctor.ts new file mode 100644 index 00000000000..a44d0f39552 --- /dev/null +++ b/extensions/slack/src/security-doctor.ts @@ -0,0 +1,21 @@ +export function isSlackMutableAllowEntry(raw: string): boolean { + const text = raw.trim(); + if (!text || text === "*") { + return false; + } + + const mentionMatch = text.match(/^<@([A-Z0-9]+)>$/i); + if (mentionMatch && /^[A-Z0-9]{8,}$/i.test(mentionMatch[1] ?? "")) { + return false; + } + + const withoutPrefix = text.replace(/^(slack|user):/i, "").trim(); + if (/^[UWBCGDT][A-Z0-9]{2,}$/.test(withoutPrefix)) { + return false; + } + if (/^[A-Z0-9]{8,}$/i.test(withoutPrefix)) { + return false; + } + + return true; +} diff --git a/extensions/slack/src/stream-mode.ts b/extensions/slack/src/stream-mode.ts index f341c0a5304..a0871e5bcb1 100644 --- a/extensions/slack/src/stream-mode.ts +++ b/extensions/slack/src/stream-mode.ts @@ -4,7 +4,7 @@ import { resolveSlackStreamingMode, type SlackLegacyDraftStreamMode, type StreamingMode, -} from "openclaw/plugin-sdk/config-runtime"; +} from "./streaming-compat.js"; export type SlackStreamMode = SlackLegacyDraftStreamMode; export type SlackStreamingMode = StreamingMode; diff --git a/extensions/slack/src/streaming-compat.ts b/extensions/slack/src/streaming-compat.ts new file mode 100644 index 00000000000..2c6fd085627 --- /dev/null +++ b/extensions/slack/src/streaming-compat.ts @@ -0,0 +1,100 @@ +export type StreamingMode = "off" | "partial" | "block" | "progress"; +export type SlackLegacyDraftStreamMode = "replace" | "status_final" | "append"; + +function normalizeStreamingMode(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + return normalized || null; +} + +function parseStreamingMode(value: unknown): StreamingMode | null { + const normalized = normalizeStreamingMode(value); + if ( + normalized === "off" || + normalized === "partial" || + normalized === "block" || + normalized === "progress" + ) { + return normalized; + } + return null; +} + +function parseSlackLegacyDraftStreamMode(value: unknown): SlackLegacyDraftStreamMode | null { + const normalized = normalizeStreamingMode(value); + if (normalized === "replace" || normalized === "status_final" || normalized === "append") { + return normalized; + } + return null; +} + +function mapSlackLegacyDraftStreamModeToStreaming(mode: SlackLegacyDraftStreamMode): StreamingMode { + if (mode === "append") { + return "block"; + } + if (mode === "status_final") { + return "progress"; + } + return "partial"; +} + +export function mapStreamingModeToSlackLegacyDraftStreamMode(mode: StreamingMode) { + if (mode === "block") { + return "append" as const; + } + if (mode === "progress") { + return "status_final" as const; + } + return "replace" as const; +} + +export function resolveSlackStreamingMode( + params: { + streamMode?: unknown; + streaming?: unknown; + } = {}, +): StreamingMode { + const parsedStreaming = parseStreamingMode(params.streaming); + if (parsedStreaming) { + return parsedStreaming; + } + const legacyStreamMode = parseSlackLegacyDraftStreamMode(params.streamMode); + if (legacyStreamMode) { + return mapSlackLegacyDraftStreamModeToStreaming(legacyStreamMode); + } + if (typeof params.streaming === "boolean") { + return params.streaming ? "partial" : "off"; + } + return "partial"; +} + +export function resolveSlackNativeStreaming( + params: { + nativeStreaming?: unknown; + streaming?: unknown; + } = {}, +): boolean { + if (typeof params.nativeStreaming === "boolean") { + return params.nativeStreaming; + } + if (typeof params.streaming === "boolean") { + return params.streaming; + } + return true; +} + +export function formatSlackStreamModeMigrationMessage( + pathPrefix: string, + resolvedStreaming: string, +): string { + return `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming (${resolvedStreaming}).`; +} + +export function formatSlackStreamingBooleanMigrationMessage( + pathPrefix: string, + resolvedNativeStreaming: boolean, +): string { + return `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`; +} diff --git a/extensions/synology-chat/contract-api.ts b/extensions/synology-chat/contract-api.ts new file mode 100644 index 00000000000..3836669cc09 --- /dev/null +++ b/extensions/synology-chat/contract-api.ts @@ -0,0 +1 @@ +export { collectSynologyChatSecurityAuditFindings } from "./src/security-audit.js"; diff --git a/extensions/telegram/api.ts b/extensions/telegram/api.ts index f6ef1ea8d09..06dffa6169c 100644 --- a/extensions/telegram/api.ts +++ b/extensions/telegram/api.ts @@ -4,6 +4,7 @@ export * from "./src/action-threading.js"; export * from "./src/allow-from.js"; export * from "./src/api-fetch.js"; export * from "./src/bot/helpers.js"; +export * from "./src/command-config.js"; export { buildCommandsPaginationKeyboard, buildTelegramModelsProviderChannelData, @@ -27,6 +28,7 @@ export * from "./src/security-audit.js"; export * from "./src/sticker-cache.js"; export * from "./src/status-issues.js"; export * from "./src/targets.js"; +export * from "./src/topic-conversation.js"; export * from "./src/update-offset-store.js"; export type { TelegramButtonStyle, TelegramInlineButtons } from "./src/button-types.js"; export type { StickerMetadata } from "./src/bot/types.js"; diff --git a/extensions/telegram/contract-api.ts b/extensions/telegram/contract-api.ts new file mode 100644 index 00000000000..a1cefdd4130 --- /dev/null +++ b/extensions/telegram/contract-api.ts @@ -0,0 +1,13 @@ +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; +export { parseTelegramTopicConversation } from "./src/topic-conversation.js"; +export { singleAccountKeysToMove } from "./src/setup-contract.js"; +export { buildTelegramModelsProviderChannelData } from "./src/command-ui.js"; +export type { + TelegramInteractiveHandlerContext, + TelegramInteractiveHandlerRegistration, +} from "./src/interactive-dispatch.js"; +export { collectTelegramSecurityAuditFindings } from "./src/security-audit.js"; diff --git a/extensions/telegram/src/bot-native-command-menu.ts b/extensions/telegram/src/bot-native-command-menu.ts index abe03d423e0..e39c48b7c2a 100644 --- a/extensions/telegram/src/bot-native-command-menu.ts +++ b/extensions/telegram/src/bot-native-command-menu.ts @@ -3,14 +3,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { Bot } from "grammy"; -import { - normalizeTelegramCommandName, - TELEGRAM_COMMAND_NAME_PATTERN, -} from "openclaw/plugin-sdk/config-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js"; export const TELEGRAM_MAX_COMMANDS = 100; const TELEGRAM_COMMAND_RETRY_RATIO = 0.8; diff --git a/extensions/telegram/src/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts index 10fcf86934a..d9673afaafa 100644 --- a/extensions/telegram/src/bot/helpers.ts +++ b/extensions/telegram/src/bot/helpers.ts @@ -1,6 +1,5 @@ import type { Chat, Message } from "@grammyjs/types"; import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; -import { resolveTelegramPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramDirectConfig, TelegramGroupConfig, @@ -10,6 +9,7 @@ import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runt import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js"; import { normalizeTelegramReplyToMessageId } from "../outbound-params.js"; +import { resolveTelegramPreviewStreamMode } from "../preview-streaming.js"; import { buildSenderLabel, buildSenderName, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 08b6ce82eea..98d4436c830 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -80,6 +80,7 @@ import { formatDuplicateTelegramTokenReason, telegramConfigAdapter, } from "./shared.js"; +import { detectTelegramLegacyStateMigrations } from "./state-migrations.js"; import { collectTelegramStatusIssues } from "./status-issues.js"; import { parseTelegramTarget } from "./targets.js"; import { @@ -724,6 +725,8 @@ export const telegramPlugin = createChatChannelPlugin({ await resolveTelegramTargets({ cfg, accountId, inputs, kind }), }, lifecycle: { + detectLegacyStateMigrations: ({ cfg, env }) => + detectTelegramLegacyStateMigrations({ cfg, env }), onAccountConfigChanged: async ({ prevCfg, nextCfg, accountId }) => { const previousToken = resolveTelegramAccount({ cfg: prevCfg, accountId }).token.trim(); const nextToken = resolveTelegramAccount({ cfg: nextCfg, accountId }).token.trim(); diff --git a/extensions/telegram/src/command-config.ts b/extensions/telegram/src/command-config.ts index d754d917a74..e7c316791d7 100644 --- a/extensions/telegram/src/command-config.ts +++ b/extensions/telegram/src/command-config.ts @@ -20,7 +20,7 @@ export function normalizeTelegramCommandName(value: string): string { return withoutSlash.trim().toLowerCase().replace(/-/g, "_"); } -function normalizeTelegramCommandDescription(value: string): string { +export function normalizeTelegramCommandDescription(value: string): string { return value.trim(); } diff --git a/extensions/telegram/src/doctor-contract.ts b/extensions/telegram/src/doctor-contract.ts new file mode 100644 index 00000000000..3cada065691 --- /dev/null +++ b/extensions/telegram/src/doctor-contract.ts @@ -0,0 +1,199 @@ +import type { + ChannelDoctorConfigMutation, + ChannelDoctorLegacyConfigRule, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveTelegramPreviewStreamMode } from "./preview-streaming.js"; + +function asObjectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function normalizeTelegramStreamingAliases(params: { + entry: Record; + pathPrefix: string; + changes: string[]; +}): { entry: Record; changed: boolean } { + let updated = params.entry; + const hadLegacyStreamMode = updated.streamMode !== undefined; + const beforeStreaming = updated.streaming; + const resolved = resolveTelegramPreviewStreamMode(updated); + const shouldNormalize = + hadLegacyStreamMode || + typeof beforeStreaming === "boolean" || + (typeof beforeStreaming === "string" && beforeStreaming !== resolved); + if (!shouldNormalize) { + return { entry: updated, changed: false }; + } + + let changed = false; + if (beforeStreaming !== resolved) { + updated = { ...updated, streaming: resolved }; + changed = true; + } + if (hadLegacyStreamMode) { + const { streamMode: _ignored, ...rest } = updated; + updated = rest; + changed = true; + params.changes.push( + `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`, + ); + } + if (typeof beforeStreaming === "boolean") { + params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`); + } else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) { + params.changes.push( + `Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`, + ); + } + return { entry: updated, changed }; +} + +function hasLegacyTelegramStreamingAliases(value: unknown): boolean { + const entry = asObjectRecord(value); + if (!entry) { + return false; + } + return ( + entry.streamMode !== undefined || + typeof entry.streaming === "boolean" || + (typeof entry.streaming === "string" && + entry.streaming !== resolveTelegramPreviewStreamMode(entry)) + ); +} + +function hasLegacyTelegramAccountStreamingAliases(value: unknown): boolean { + const accounts = asObjectRecord(value); + if (!accounts) { + return false; + } + return Object.values(accounts).some((account) => hasLegacyTelegramStreamingAliases(account)); +} + +function resolveCompatibleDefaultGroupEntry(section: Record): { + groups: Record; + entry: Record; +} | null { + const existingGroups = section.groups; + if (existingGroups !== undefined && !asObjectRecord(existingGroups)) { + return null; + } + const groups = asObjectRecord(existingGroups) ?? {}; + const defaultKey = "*"; + const existingEntry = groups[defaultKey]; + if (existingEntry !== undefined && !asObjectRecord(existingEntry)) { + return null; + } + const entry = asObjectRecord(existingEntry) ?? {}; + return { groups, entry }; +} + +export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "telegram", "groupMentionsOnly"], + message: + 'channels.telegram.groupMentionsOnly was removed; use channels.telegram.groups."*".requireMention instead (auto-migrated on load).', + }, + { + path: ["channels", "telegram"], + message: + 'channels.telegram.streamMode and boolean channels.telegram.streaming are legacy; use channels.telegram.streaming="off|partial|block".', + match: hasLegacyTelegramStreamingAliases, + }, + { + path: ["channels", "telegram", "accounts"], + message: + 'channels.telegram.accounts..streamMode and boolean channels.telegram.accounts..streaming are legacy; use channels.telegram.accounts..streaming="off|partial|block".', + match: hasLegacyTelegramAccountStreamingAliases, + }, +]; + +export function normalizeCompatibilityConfig({ + cfg, +}: { + cfg: OpenClawConfig; +}): ChannelDoctorConfigMutation { + const rawEntry = asObjectRecord((cfg.channels as Record | undefined)?.telegram); + if (!rawEntry) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updated = rawEntry; + let changed = false; + + if (updated.groupMentionsOnly !== undefined) { + const defaultGroupEntry = resolveCompatibleDefaultGroupEntry(updated); + if (!defaultGroupEntry) { + changes.push( + "Skipped channels.telegram.groupMentionsOnly migration because channels.telegram.groups already has an incompatible shape; fix remaining issues manually.", + ); + } else { + const { groups, entry } = defaultGroupEntry; + if (entry.requireMention === undefined) { + entry.requireMention = updated.groupMentionsOnly; + groups["*"] = entry; + updated = { ...updated, groups }; + changes.push( + 'Moved channels.telegram.groupMentionsOnly → channels.telegram.groups."*".requireMention.', + ); + } else { + changes.push( + 'Removed channels.telegram.groupMentionsOnly (channels.telegram.groups."*" already set).', + ); + } + const { groupMentionsOnly: _ignored, ...rest } = updated; + updated = rest; + changed = true; + } + } + + const base = normalizeTelegramStreamingAliases({ + entry: updated, + pathPrefix: "channels.telegram", + changes, + }); + updated = base.entry; + changed = changed || base.changed; + + const rawAccounts = asObjectRecord(updated.accounts); + if (rawAccounts) { + let accountsChanged = false; + const accounts = { ...rawAccounts }; + for (const [accountId, rawAccount] of Object.entries(rawAccounts)) { + const account = asObjectRecord(rawAccount); + if (!account) { + continue; + } + const accountStreaming = normalizeTelegramStreamingAliases({ + entry: account, + pathPrefix: `channels.telegram.accounts.${accountId}`, + changes, + }); + if (accountStreaming.changed) { + accounts[accountId] = accountStreaming.entry; + accountsChanged = true; + } + } + if (accountsChanged) { + updated = { ...updated, accounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + telegram: updated as unknown as NonNullable["telegram"], + } as OpenClawConfig["channels"], + }, + changes, + }; +} diff --git a/extensions/telegram/src/doctor.ts b/extensions/telegram/src/doctor.ts index b998008687c..9768f253287 100644 --- a/extensions/telegram/src/doctor.ts +++ b/extensions/telegram/src/doctor.ts @@ -2,11 +2,9 @@ import { type ChannelDoctorAdapter, type ChannelDoctorConfigMutation, type ChannelDoctorEmptyAllowlistAccountContext, + type ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; -import { - resolveTelegramPreviewStreamMode, - type OpenClawConfig, -} from "openclaw/plugin-sdk/config-runtime"; +import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { getChannelsCommandSecretTargetIds, resolveCommandSecretRefsViaGateway, @@ -15,6 +13,7 @@ import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry } from "./allow-from.js"; import { lookupTelegramChatId } from "./api-fetch.js"; +import { resolveTelegramPreviewStreamMode } from "./preview-streaming.js"; type TelegramAllowFromUsernameHit = { path: string; entry: string }; type DoctorAllowFromList = Array; @@ -452,7 +451,44 @@ export function collectTelegramEmptyAllowlistExtraWarnings( : []; } +function hasLegacyTelegramStreamingAliases(value: unknown): boolean { + const entry = asObjectRecord(value); + if (!entry) { + return false; + } + return ( + entry.streamMode !== undefined || + typeof entry.streaming === "boolean" || + (typeof entry.streaming === "string" && + entry.streaming !== resolveTelegramPreviewStreamMode(entry)) + ); +} + +function hasLegacyTelegramAccountStreamingAliases(value: unknown): boolean { + const accounts = asObjectRecord(value); + if (!accounts) { + return false; + } + return Object.values(accounts).some((account) => hasLegacyTelegramStreamingAliases(account)); +} + +const TELEGRAM_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [ + { + path: ["channels", "telegram"], + message: + 'channels.telegram.streamMode and boolean channels.telegram.streaming are legacy; use channels.telegram.streaming="off|partial|block".', + match: hasLegacyTelegramStreamingAliases, + }, + { + path: ["channels", "telegram", "accounts"], + message: + 'channels.telegram.accounts..streamMode and boolean channels.telegram.accounts..streaming are legacy; use channels.telegram.accounts..streaming="off|partial|block".', + match: hasLegacyTelegramAccountStreamingAliases, + }, +]; + export const telegramDoctor: ChannelDoctorAdapter = { + legacyConfigRules: TELEGRAM_LEGACY_CONFIG_RULES, normalizeCompatibilityConfig: ({ cfg }) => normalizeTelegramCompatibilityConfig(cfg), collectPreviewWarnings: ({ cfg, doctorFixCommand }) => collectTelegramAllowFromUsernameWarnings({ diff --git a/extensions/telegram/src/preview-streaming.ts b/extensions/telegram/src/preview-streaming.ts new file mode 100644 index 00000000000..86e679ddd64 --- /dev/null +++ b/extensions/telegram/src/preview-streaming.ts @@ -0,0 +1,54 @@ +export type TelegramPreviewStreamMode = "off" | "partial" | "block"; + +function normalizeStreamingMode(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + return normalized || null; +} + +function parseStreamingMode(value: unknown): "off" | "partial" | "block" | "progress" | null { + const normalized = normalizeStreamingMode(value); + if ( + normalized === "off" || + normalized === "partial" || + normalized === "block" || + normalized === "progress" + ) { + return normalized; + } + return null; +} + +function parseTelegramPreviewStreamMode(value: unknown): TelegramPreviewStreamMode | null { + const parsed = parseStreamingMode(value); + if (!parsed) { + return null; + } + return parsed === "progress" ? "partial" : parsed; +} + +export function resolveTelegramPreviewStreamMode( + params: { + streamMode?: unknown; + streaming?: unknown; + } = {}, +): TelegramPreviewStreamMode { + const parsedStreaming = parseStreamingMode(params.streaming); + if (parsedStreaming) { + if (parsedStreaming === "progress") { + return "partial"; + } + return parsedStreaming; + } + + const legacy = parseTelegramPreviewStreamMode(params.streamMode); + if (legacy) { + return legacy; + } + if (typeof params.streaming === "boolean") { + return params.streaming ? "partial" : "off"; + } + return "partial"; +} diff --git a/extensions/telegram/src/secret-contract.ts b/extensions/telegram/src/secret-contract.ts new file mode 100644 index 00000000000..a55d9c3b08f --- /dev/null +++ b/extensions/telegram/src/secret-contract.ts @@ -0,0 +1,117 @@ +import { + collectConditionalChannelFieldAssignments, + getChannelSurface, + hasConfiguredSecretInputValue, + hasOwnProperty, + type ResolverContext, + type SecretDefaults, + type SecretTargetRegistryEntry, +} from "openclaw/plugin-sdk/security-runtime"; + +export const secretTargetRegistryEntries = [ + { + id: "channels.telegram.accounts.*.botToken", + targetType: "channels.telegram.accounts.*.botToken", + configFile: "openclaw.json", + pathPattern: "channels.telegram.accounts.*.botToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.telegram.accounts.*.webhookSecret", + targetType: "channels.telegram.accounts.*.webhookSecret", + configFile: "openclaw.json", + pathPattern: "channels.telegram.accounts.*.webhookSecret", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.telegram.botToken", + targetType: "channels.telegram.botToken", + configFile: "openclaw.json", + pathPattern: "channels.telegram.botToken", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, + { + id: "channels.telegram.webhookSecret", + targetType: "channels.telegram.webhookSecret", + configFile: "openclaw.json", + pathPattern: "channels.telegram.webhookSecret", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, +] satisfies SecretTargetRegistryEntry[]; + +export function collectRuntimeConfigAssignments(params: { + config: { channels?: Record }; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const resolved = getChannelSurface(params.config, "telegram"); + if (!resolved) { + return; + } + const { channel: telegram, surface } = resolved; + const baseTokenFile = typeof telegram.tokenFile === "string" ? telegram.tokenFile.trim() : ""; + const accountTokenFile = (account: Record) => + typeof account.tokenFile === "string" ? account.tokenFile.trim() : ""; + collectConditionalChannelFieldAssignments({ + channelKey: "telegram", + field: "botToken", + channel: telegram, + surface, + defaults: params.defaults, + context: params.context, + topLevelActiveWithoutAccounts: baseTokenFile.length === 0, + topLevelInheritedAccountActive: ({ account, enabled }) => { + if (!enabled || baseTokenFile.length > 0) { + return false; + } + const accountBotTokenConfigured = hasConfiguredSecretInputValue( + account.botToken, + params.defaults, + ); + return !accountBotTokenConfigured && accountTokenFile(account).length === 0; + }, + accountActive: ({ account, enabled }) => enabled && accountTokenFile(account).length === 0, + topInactiveReason: + "no enabled Telegram surface inherits this top-level botToken (tokenFile is configured).", + accountInactiveReason: "Telegram account is disabled or tokenFile is configured.", + }); + const baseWebhookUrl = typeof telegram.webhookUrl === "string" ? telegram.webhookUrl.trim() : ""; + const accountWebhookUrl = (account: Record) => + hasOwnProperty(account, "webhookUrl") + ? typeof account.webhookUrl === "string" + ? account.webhookUrl.trim() + : "" + : baseWebhookUrl; + collectConditionalChannelFieldAssignments({ + channelKey: "telegram", + field: "webhookSecret", + channel: telegram, + surface, + defaults: params.defaults, + context: params.context, + topLevelActiveWithoutAccounts: baseWebhookUrl.length > 0, + topLevelInheritedAccountActive: ({ account, enabled }) => + enabled && !hasOwnProperty(account, "webhookSecret") && accountWebhookUrl(account).length > 0, + accountActive: ({ account, enabled }) => enabled && accountWebhookUrl(account).length > 0, + topInactiveReason: + "no enabled Telegram webhook surface inherits this top-level webhookSecret (webhook mode is not active).", + accountInactiveReason: + "Telegram account is disabled or webhook mode is not active for this account.", + }); +} diff --git a/extensions/telegram/src/setup-contract.ts b/extensions/telegram/src/setup-contract.ts new file mode 100644 index 00000000000..8d1d8be6852 --- /dev/null +++ b/extensions/telegram/src/setup-contract.ts @@ -0,0 +1 @@ +export const singleAccountKeysToMove = ["streaming"]; diff --git a/extensions/telegram/src/state-migrations.ts b/extensions/telegram/src/state-migrations.ts new file mode 100644 index 00000000000..19147405828 --- /dev/null +++ b/extensions/telegram/src/state-migrations.ts @@ -0,0 +1,36 @@ +import fs from "node:fs"; +import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract"; +import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveDefaultTelegramAccountId } from "./accounts.js"; + +function fileExists(pathValue: string): boolean { + try { + return fs.existsSync(pathValue) && fs.statSync(pathValue).isFile(); + } catch { + return false; + } +} + +export function detectTelegramLegacyStateMigrations(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): ChannelLegacyStateMigrationPlan[] { + const legacyPath = resolveChannelAllowFromPath("telegram", params.env); + if (!fileExists(legacyPath)) { + return []; + } + const accountId = resolveDefaultTelegramAccountId(params.cfg); + const targetPath = resolveChannelAllowFromPath("telegram", params.env, accountId); + if (fileExists(targetPath)) { + return []; + } + return [ + { + kind: "copy", + label: "Telegram pairing allowFrom", + sourcePath: legacyPath, + targetPath, + }, + ]; +} diff --git a/extensions/whatsapp/api.ts b/extensions/whatsapp/api.ts index e5a50c2e5ee..f5661c1d8dc 100644 --- a/extensions/whatsapp/api.ts +++ b/extensions/whatsapp/api.ts @@ -3,6 +3,7 @@ export * from "./src/auto-reply/constants.js"; export { whatsappCommandPolicy } from "./src/command-policy.js"; export * from "./src/group-policy.js"; export { WHATSAPP_LEGACY_OUTBOUND_SEND_DEP_KEYS } from "./src/outbound-send-deps.js"; +export * from "./src/text-runtime.js"; export type * from "./src/auto-reply/types.js"; export type * from "./src/inbound/types.js"; export { @@ -14,6 +15,8 @@ export { isWhatsAppGroupJid, normalizeWhatsAppAllowFromEntries, isWhatsAppUserTarget, + looksLikeWhatsAppTargetId, + normalizeWhatsAppMessagingTarget, normalizeWhatsAppTarget, } from "./src/normalize-target.js"; export { resolveWhatsAppGroupIntroHint } from "./src/runtime-api.js"; diff --git a/extensions/whatsapp/contract-api.ts b/extensions/whatsapp/contract-api.ts new file mode 100644 index 00000000000..3cd1e8fde37 --- /dev/null +++ b/extensions/whatsapp/contract-api.ts @@ -0,0 +1,53 @@ +type UnsupportedSecretRefConfigCandidate = { + path: string; + value: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export const unsupportedSecretRefSurfacePatterns = [ + "channels.whatsapp.creds.json", + "channels.whatsapp.accounts.*.creds.json", +] as const; + +export { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./src/session-contract.js"; +export { createWhatsAppPollFixture, expectWhatsAppPollSent } from "./src/outbound-test-support.js"; +export { whatsappCommandPolicy } from "./src/command-policy.js"; +export { resolveLegacyGroupSessionKey } from "./src/group-session-contract.js"; +export { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./src/normalize-target.js"; +export { __testing as whatsappAccessControlTesting } from "./src/inbound/access-control.js"; + +export function collectUnsupportedSecretRefConfigCandidates( + raw: unknown, +): UnsupportedSecretRefConfigCandidate[] { + if (!isRecord(raw.channels) || !isRecord(raw.channels.whatsapp)) { + return []; + } + + const candidates: UnsupportedSecretRefConfigCandidate[] = []; + const whatsapp = raw.channels.whatsapp; + const creds = isRecord(whatsapp.creds) ? whatsapp.creds : null; + if (creds) { + candidates.push({ + path: "channels.whatsapp.creds.json", + value: creds.json, + }); + } + + const accounts = isRecord(whatsapp.accounts) ? whatsapp.accounts : null; + if (!accounts) { + return candidates; + } + for (const [accountId, account] of Object.entries(accounts)) { + if (!isRecord(account) || !isRecord(account.creds)) { + continue; + } + candidates.push({ + path: `channels.whatsapp.accounts.${accountId}.creds.json`, + value: account.creds.json, + }); + } + return candidates; +} diff --git a/extensions/whatsapp/src/auth-store.ts b/extensions/whatsapp/src/auth-store.ts index ee214492620..172306afa67 100644 --- a/extensions/whatsapp/src/auth-store.ts +++ b/extensions/whatsapp/src/auth-store.ts @@ -6,11 +6,10 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { info, success } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import type { WebChannel } from "openclaw/plugin-sdk/text-runtime"; -import { resolveUserPath } from "openclaw/plugin-sdk/text-runtime"; import { resolveOAuthDir } from "./auth-store.runtime.js"; import { hasWebCredsSync, resolveWebCredsBackupPath, resolveWebCredsPath } from "./creds-files.js"; import { resolveComparableIdentity, type WhatsAppSelfIdentity } from "./identity.js"; +import { resolveUserPath, type WebChannel } from "./text-runtime.js"; export { hasWebCredsSync, resolveWebCredsBackupPath, resolveWebCredsPath }; export function resolveDefaultWebAuthDir(): string { diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.ts index 5cf5996841b..0557c989348 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.ts @@ -6,12 +6,11 @@ import { sendMediaWithLeadingCaption, } from "openclaw/plugin-sdk/reply-payload"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; -import { markdownToWhatsApp } from "openclaw/plugin-sdk/text-runtime"; -import { sleep } from "openclaw/plugin-sdk/text-runtime"; import { loadWebMedia } from "../media.js"; import { newConnectionId } from "../reconnect.js"; import { formatError } from "../session.js"; +import { convertMarkdownTables, sleep } from "../text-runtime.js"; +import { markdownToWhatsApp } from "../text-runtime.js"; import { whatsappOutboundLog } from "./loggers.js"; import type { WebInboundMsg } from "./types.js"; import { elide } from "./util.js"; diff --git a/extensions/whatsapp/src/auto-reply/mentions.ts b/extensions/whatsapp/src/auto-reply/mentions.ts index 35bd60ea191..df1130fd794 100644 --- a/extensions/whatsapp/src/auto-reply/mentions.ts +++ b/extensions/whatsapp/src/auto-reply/mentions.ts @@ -1,6 +1,5 @@ import { buildMentionRegexes, normalizeMentionText } from "openclaw/plugin-sdk/channel-inbound"; import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; -import { isSelfChatMode, normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import { getComparableIdentityValues, getMentionIdentities, @@ -8,6 +7,7 @@ import { identitiesOverlap, type WhatsAppIdentity, } from "../identity.js"; +import { isSelfChatMode, normalizeE164 } from "../text-runtime.js"; import type { WebInboundMsg } from "./types.js"; export type MentionConfig = { diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.runtime.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.runtime.ts index e3d58e6ecc4..4502f9b8336 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.runtime.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.runtime.ts @@ -2,4 +2,4 @@ export { resolveMentionGating } from "openclaw/plugin-sdk/channel-inbound"; export { hasControlCommand } from "openclaw/plugin-sdk/command-detection"; export { recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-history"; export { parseActivationCommand } from "openclaw/plugin-sdk/reply-runtime"; -export { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; +export { normalizeE164 } from "../../text-runtime.js"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-members.ts b/extensions/whatsapp/src/auto-reply/monitor/group-members.ts index a037dcfb38b..ca8787af9a5 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-members.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-members.ts @@ -1,4 +1,4 @@ -import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; +import { normalizeE164 } from "../../text-runtime.js"; function appendNormalizedUnique(entries: Iterable, seen: Set, ordered: string[]) { for (const entry of entries) { diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-context.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-context.ts index da0ff7ac238..5b84ee67b2e 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-context.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-context.ts @@ -2,13 +2,13 @@ import { evaluateSupplementalContextVisibility, filterSupplementalContextItems, } from "openclaw/plugin-sdk/security-runtime"; -import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import { getComparableIdentityValues, getReplyContext, type WhatsAppIdentity, type WhatsAppReplyContext, } from "../../identity.js"; +import { normalizeE164 } from "../../text-runtime.js"; import type { WebInboundMsg } from "../types.js"; export type GroupHistoryEntry = { diff --git a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts index 4c0d180d6c1..1b175bdf929 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts @@ -3,8 +3,8 @@ import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { buildGroupHistoryKey } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import { getPrimaryIdentityId, getSenderIdentity } from "../../identity.js"; +import { normalizeE164 } from "../../text-runtime.js"; import { loadConfig } from "../config.runtime.js"; import type { MentionConfig } from "../mentions.js"; import type { WebInboundMsg } from "../types.js"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/peer.ts b/extensions/whatsapp/src/auto-reply/monitor/peer.ts index 23b908a3c1a..8437eb8b6f8 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/peer.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/peer.ts @@ -1,5 +1,5 @@ -import { jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import { getSenderIdentity } from "../../identity.js"; +import { jidToE164, normalizeE164 } from "../../text-runtime.js"; import type { WebInboundMsg } from "../types.js"; export function resolvePeerId(msg: WebInboundMsg) { diff --git a/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts b/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts index 889bdc35429..dbc78cf9c83 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts @@ -36,4 +36,4 @@ export { resolvePinnedMainDmOwnerFromAllowlist, } from "openclaw/plugin-sdk/security-runtime"; export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; -export { jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; +export { jidToE164, normalizeE164 } from "../../text-runtime.js"; diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index e934909e8c4..9ff4207c7a0 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -44,6 +44,7 @@ import { loadWhatsAppChannelRuntime, whatsappSetupWizardProxy, } from "./shared.js"; +import { detectWhatsAppLegacyStateMigrations } from "./state-migrations.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; function parseWhatsAppExplicitTarget(raw: string) { @@ -145,6 +146,10 @@ export const whatsappPlugin: ChannelPlugin = ).loginWeb(Boolean(verbose), undefined, runtime, resolvedAccountId); }, }, + lifecycle: { + detectLegacyStateMigrations: ({ oauthDir }) => + detectWhatsAppLegacyStateMigrations({ oauthDir }), + }, heartbeat: { checkReady: async ({ cfg, accountId, deps }) => { if (cfg.web?.enabled === false) { diff --git a/extensions/whatsapp/src/group-session-contract.ts b/extensions/whatsapp/src/group-session-contract.ts new file mode 100644 index 00000000000..e28fa533b43 --- /dev/null +++ b/extensions/whatsapp/src/group-session-contract.ts @@ -0,0 +1,18 @@ +export function resolveLegacyGroupSessionKey(ctx: { From?: string }): { + key: string; + channel: string; + id: string; + chatType: "group"; +} | null { + const from = typeof ctx.From === "string" ? ctx.From.trim() : ""; + if (!from || from.includes(":") || !from.toLowerCase().endsWith("@g.us")) { + return null; + } + const normalized = from.toLowerCase(); + return { + key: `whatsapp:group:${normalized}`, + channel: "whatsapp", + id: normalized, + chatType: "group", + }; +} diff --git a/extensions/whatsapp/src/identity.ts b/extensions/whatsapp/src/identity.ts index a9ee56f27fd..3df32046f5f 100644 --- a/extensions/whatsapp/src/identity.ts +++ b/extensions/whatsapp/src/identity.ts @@ -1,4 +1,4 @@ -import { jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; +import { jidToE164, normalizeE164 } from "./text-runtime.js"; const WHATSAPP_LID_RE = /@(lid|hosted\.lid)$/i; diff --git a/extensions/whatsapp/src/inbound/access-control.ts b/extensions/whatsapp/src/inbound/access-control.ts index 95fe6dd487a..6dfcf890467 100644 --- a/extensions/whatsapp/src/inbound/access-control.ts +++ b/extensions/whatsapp/src/inbound/access-control.ts @@ -11,8 +11,8 @@ import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, } from "openclaw/plugin-sdk/security-runtime"; -import { isSelfChatMode, normalizeE164 } from "openclaw/plugin-sdk/text-runtime"; import { resolveWhatsAppAccount } from "../accounts.js"; +import { isSelfChatMode, normalizeE164 } from "../text-runtime.js"; export type InboundAccessControlResult = { allowed: boolean; diff --git a/extensions/whatsapp/src/inbound/extract.ts b/extensions/whatsapp/src/inbound/extract.ts index d17cefdfee8..c8bfbfa2757 100644 --- a/extensions/whatsapp/src/inbound/extract.ts +++ b/extensions/whatsapp/src/inbound/extract.ts @@ -6,8 +6,8 @@ import { } from "@whiskeysockets/baileys"; import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { jidToE164 } from "openclaw/plugin-sdk/text-runtime"; import { resolveComparableIdentity, type WhatsAppReplyContext } from "../identity.js"; +import { jidToE164 } from "../text-runtime.js"; import { parseVcard } from "../vcard.js"; const MESSAGE_WRAPPER_KEYS = [ diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 78578da4489..fe51afb7f42 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -4,10 +4,10 @@ import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/text-runtime"; -import { resolveJidToE164 } from "openclaw/plugin-sdk/text-runtime"; import { readWebSelfIdentity } from "../auth-store.js"; import { getPrimaryIdentityId, resolveComparableIdentity } from "../identity.js"; import { createWaSocket, getStatusCode, waitForWaConnection } from "../session.js"; +import { resolveJidToE164 } from "../text-runtime.js"; import { checkInboundAccessControl } from "./access-control.js"; import { isRecentInboundMessage, diff --git a/extensions/whatsapp/src/inbound/send-api.ts b/extensions/whatsapp/src/inbound/send-api.ts index 8941e56ba7d..c95b906d2f2 100644 --- a/extensions/whatsapp/src/inbound/send-api.ts +++ b/extensions/whatsapp/src/inbound/send-api.ts @@ -1,7 +1,7 @@ import type { AnyMessageContent, WAPresence } from "@whiskeysockets/baileys"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; -import { toWhatsappJid } from "openclaw/plugin-sdk/text-runtime"; import type { ActiveWebSendOptions } from "../active-listener.js"; +import { toWhatsappJid } from "../text-runtime.js"; function recordWhatsAppOutbound(accountId: string) { recordChannelActivity({ diff --git a/extensions/whatsapp/src/normalize-target.ts b/extensions/whatsapp/src/normalize-target.ts index 2bc2a77b8b7..8797488f231 100644 --- a/extensions/whatsapp/src/normalize-target.ts +++ b/extensions/whatsapp/src/normalize-target.ts @@ -1,6 +1,7 @@ import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution"; const WHATSAPP_USER_JID_RE = /^(\d+)(?::\d+)?@s\.whatsapp\.net$/i; +const WHATSAPP_LEGACY_USER_JID_RE = /^(\d+)@c\.us$/i; const WHATSAPP_LID_RE = /^(\d+)@lid$/i; function stripWhatsAppTargetPrefixes(value: string): string { @@ -29,7 +30,11 @@ export function isWhatsAppGroupJid(value: string): boolean { export function isWhatsAppUserTarget(value: string): boolean { const candidate = stripWhatsAppTargetPrefixes(value); - return WHATSAPP_USER_JID_RE.test(candidate) || WHATSAPP_LID_RE.test(candidate); + return ( + WHATSAPP_USER_JID_RE.test(candidate) || + WHATSAPP_LEGACY_USER_JID_RE.test(candidate) || + WHATSAPP_LID_RE.test(candidate) + ); } function extractUserJidPhone(jid: string): string | null { @@ -37,6 +42,10 @@ function extractUserJidPhone(jid: string): string | null { if (userMatch) { return userMatch[1]; } + const legacyUserMatch = jid.match(WHATSAPP_LEGACY_USER_JID_RE); + if (legacyUserMatch) { + return legacyUserMatch[1]; + } const lidMatch = jid.match(WHATSAPP_LID_RE); if (lidMatch) { return lidMatch[1]; diff --git a/extensions/whatsapp/src/outbound-test-support.ts b/extensions/whatsapp/src/outbound-test-support.ts new file mode 100644 index 00000000000..273279fa14f --- /dev/null +++ b/extensions/whatsapp/src/outbound-test-support.ts @@ -0,0 +1,33 @@ +import { expect, type MockInstance } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; + +export function createWhatsAppPollFixture() { + const cfg = { marker: "resolved-cfg" } as OpenClawConfig; + const poll = { + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + }; + return { + cfg, + poll, + to: "+1555", + accountId: "work", + }; +} + +export function expectWhatsAppPollSent( + sendPollWhatsApp: MockInstance, + params: { + cfg: OpenClawConfig; + poll: { question: string; options: string[]; maxSelections: number }; + to?: string; + accountId?: string; + }, +) { + expect(sendPollWhatsApp).toHaveBeenCalledWith(params.to ?? "+1555", params.poll, { + verbose: false, + accountId: params.accountId ?? "work", + cfg: params.cfg, + }); +} diff --git a/extensions/whatsapp/src/security-fix.ts b/extensions/whatsapp/src/security-fix.ts new file mode 100644 index 00000000000..23c43f4d67e --- /dev/null +++ b/extensions/whatsapp/src/security-fix.ts @@ -0,0 +1,73 @@ +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; +import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract"; +import { readChannelAllowFromStore } from "openclaw/plugin-sdk/channel-pairing"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; + +function applyGroupAllowFromFromStore(params: { + cfg: OpenClawConfig; + storeAllowFrom: string[]; + changes: string[]; +}): OpenClawConfig { + const next = structuredClone(params.cfg ?? {}); + const section = next.channels?.whatsapp as Record | undefined; + if (!section || typeof section !== "object" || params.storeAllowFrom.length === 0) { + return params.cfg; + } + + let changed = false; + const maybeApply = (prefix: string, holder: Record) => { + if (holder.groupPolicy !== "allowlist") { + return; + } + const allowFrom = Array.isArray(holder.allowFrom) ? holder.allowFrom : []; + const groupAllowFrom = Array.isArray(holder.groupAllowFrom) ? holder.groupAllowFrom : []; + if (allowFrom.length > 0 || groupAllowFrom.length > 0) { + return; + } + holder.groupAllowFrom = params.storeAllowFrom; + params.changes.push(`${prefix}groupAllowFrom=pairing-store`); + changed = true; + }; + + maybeApply("channels.whatsapp.", section); + + const accounts = section.accounts; + if (accounts && typeof accounts === "object") { + for (const [accountId, accountValue] of Object.entries(accounts)) { + if (!accountValue || typeof accountValue !== "object") { + continue; + } + maybeApply( + `channels.whatsapp.accounts.${accountId}.`, + accountValue as Record, + ); + } + } + + return changed ? next : params.cfg; +} + +export async function applyWhatsAppSecurityConfigFixes(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): Promise { + const fromStore = await readChannelAllowFromStore( + "whatsapp", + params.env, + DEFAULT_ACCOUNT_ID, + ).catch(() => []); + const normalized = Array.from(new Set(fromStore.map((entry) => String(entry).trim()))).filter( + Boolean, + ); + if (normalized.length === 0) { + return { config: params.cfg, changes: [] }; + } + + const changes: string[] = []; + const config = applyGroupAllowFromFromStore({ + cfg: params.cfg, + storeAllowFrom: normalized, + changes, + }); + return { config, changes }; +} diff --git a/extensions/whatsapp/src/send.ts b/extensions/whatsapp/src/send.ts index 867f70a5856..6811edd012e 100644 --- a/extensions/whatsapp/src/send.ts +++ b/extensions/whatsapp/src/send.ts @@ -6,8 +6,6 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger } from "openclaw/plugin-sdk/text-runtime"; import { redactIdentifier } from "openclaw/plugin-sdk/text-runtime"; import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; -import { markdownToWhatsApp } from "openclaw/plugin-sdk/text-runtime"; -import { toWhatsappJid } from "openclaw/plugin-sdk/text-runtime"; import { resolveDefaultWhatsAppAccountId, resolveWhatsAppAccount, @@ -15,6 +13,7 @@ import { } from "./accounts.js"; import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js"; import { loadOutboundMediaFromUrl } from "./runtime-api.js"; +import { markdownToWhatsApp, toWhatsappJid } from "./text-runtime.js"; const outboundLog = createSubsystemLogger("gateway/channels/whatsapp").child("outbound"); diff --git a/extensions/whatsapp/src/session-contract.ts b/extensions/whatsapp/src/session-contract.ts new file mode 100644 index 00000000000..1de99eb5edd --- /dev/null +++ b/extensions/whatsapp/src/session-contract.ts @@ -0,0 +1,42 @@ +export function isLegacyGroupSessionKey(key: string): boolean { + const trimmed = key.trim(); + if (!trimmed) { + return false; + } + if (trimmed.startsWith("group:")) { + return true; + } + const lower = trimmed.toLowerCase(); + if (!lower.includes("@g.us")) { + return false; + } + if (!trimmed.includes(":")) { + return true; + } + return lower.startsWith("whatsapp:") && !trimmed.includes(":group:"); +} + +export function canonicalizeLegacySessionKey(params: { + key: string; + agentId: string; +}): string | null { + const trimmed = params.key.trim(); + if (!trimmed) { + return null; + } + if (trimmed.startsWith("group:")) { + const id = trimmed.slice("group:".length).trim(); + return id ? `agent:${params.agentId}:whatsapp:group:${id}`.toLowerCase() : null; + } + if (!trimmed.includes(":") && trimmed.toLowerCase().includes("@g.us")) { + return `agent:${params.agentId}:whatsapp:group:${trimmed}`.toLowerCase(); + } + if (trimmed.toLowerCase().startsWith("whatsapp:") && trimmed.toLowerCase().includes("@g.us")) { + const remainder = trimmed.slice("whatsapp:".length).trim(); + const cleaned = remainder.replace(/^group:/i, "").trim(); + if (cleaned && !trimmed.includes(":group:")) { + return `agent:${params.agentId}:whatsapp:group:${cleaned}`.toLowerCase(); + } + } + return null; +} diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 1124b12ea1c..3a55b436939 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -5,7 +5,7 @@ import { } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; -import { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId } from "./accounts.js"; +import { listWhatsAppAccountIds } from "./accounts.js"; import { detectWhatsAppLinked, finalizeWhatsAppSetup } from "./setup-finalize.js"; const channel = "whatsapp" as const; @@ -20,26 +20,37 @@ export const whatsappSetupWizard: ChannelSetupWizard = { configuredScore: 5, unconfiguredScore: 4, resolveConfigured: async ({ cfg, accountId }) => { - return await detectWhatsAppLinked( - cfg, - accountId || resolveDefaultWhatsAppAccountId(cfg), - ); + for (const resolvedAccountId of accountId ? [accountId] : listWhatsAppAccountIds(cfg)) { + if (await detectWhatsAppLinked(cfg, resolvedAccountId)) { + return true; + } + } + return false; }, resolveStatusLines: async ({ cfg, accountId, configured }) => { - const labelAccountId = accountId || resolveDefaultWhatsAppAccountId(cfg); + const linkedAccountId = ( + await Promise.all( + (accountId ? [accountId] : listWhatsAppAccountIds(cfg)).map( + async (resolvedAccountId) => ({ + accountId: resolvedAccountId, + linked: await detectWhatsAppLinked(cfg, resolvedAccountId), + }), + ), + ) + ).find((entry) => entry.linked)?.accountId; + const labelAccountId = accountId ?? linkedAccountId; const label = labelAccountId ? `WhatsApp (${labelAccountId === DEFAULT_ACCOUNT_ID ? "default" : labelAccountId})` : "WhatsApp"; return [`${label}: ${configured ? "linked" : "not linked"}`]; }, }, - resolveShouldPromptAccountIds: ({ options, shouldPromptAccountIds }) => - Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId), + resolveShouldPromptAccountIds: ({ shouldPromptAccountIds }) => Boolean(shouldPromptAccountIds), credentials: [], finalize: async ({ cfg, accountId, forceAllowFrom, prompter, runtime }) => await finalizeWhatsAppSetup({ cfg, accountId, forceAllowFrom, prompter, runtime }), disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), onAccountRecorded: (accountId, options) => { - options?.onWhatsAppAccountId?.(accountId); + options?.onAccountId?.(channel, accountId); }, }; diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index e81384a0588..9241b4db3c2 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -27,6 +27,7 @@ import { resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, } from "./group-policy.js"; +import { applyWhatsAppSecurityConfigFixes } from "./security-fix.js"; export const WHATSAPP_CHANNEL = "whatsapp" as const; @@ -72,8 +73,7 @@ export function createWhatsAppSetupWizardProxy( configuredScore: 5, unconfiguredScore: 4, }, - resolveShouldPromptAccountIds: (params) => - (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, + resolveShouldPromptAccountIds: (params) => Boolean(params.shouldPromptAccountIds), credentials: [], delegateFinalize: true, disable: (cfg) => ({ @@ -87,7 +87,7 @@ export function createWhatsAppSetupWizardProxy( }, }), onAccountRecorded: (accountId, options) => { - options?.onWhatsAppAccountId?.(accountId); + options?.onAccountId?.(WHATSAPP_CHANNEL, accountId); }, }); } @@ -156,6 +156,7 @@ export function createWhatsAppPluginBase(params: { }), }, security: { + applyConfigFixes: applyWhatsAppSecurityConfigFixes, resolveDmPolicy: whatsappResolveDmPolicy, collectWarnings: collectWhatsAppSecurityWarnings, }, diff --git a/extensions/whatsapp/src/state-migrations.ts b/extensions/whatsapp/src/state-migrations.ts new file mode 100644 index 00000000000..d29791dd2e1 --- /dev/null +++ b/extensions/whatsapp/src/state-migrations.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import path from "node:path"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; +import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract"; + +function fileExists(pathValue: string): boolean { + try { + return fs.existsSync(pathValue) && fs.statSync(pathValue).isFile(); + } catch { + return false; + } +} + +function isLegacyWhatsAppAuthFile(name: string): boolean { + if (name === "creds.json" || name === "creds.json.bak") { + return true; + } + if (!name.endsWith(".json")) { + return false; + } + return /^(app-state-sync|session|sender-key|pre-key)-/.test(name); +} + +export function detectWhatsAppLegacyStateMigrations(params: { + oauthDir: string; +}): ChannelLegacyStateMigrationPlan[] { + const targetDir = path.join(params.oauthDir, "whatsapp", DEFAULT_ACCOUNT_ID); + const entries = (() => { + try { + return fs.readdirSync(params.oauthDir, { withFileTypes: true }); + } catch { + return []; + } + })(); + + return entries.flatMap((entry) => { + if (!entry.isFile() || entry.name === "oauth.json" || !isLegacyWhatsAppAuthFile(entry.name)) { + return []; + } + const sourcePath = path.join(params.oauthDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + if (fileExists(targetPath)) { + return []; + } + return [ + { + kind: "move" as const, + label: `WhatsApp auth ${entry.name}`, + sourcePath, + targetPath, + }, + ]; + }); +} diff --git a/extensions/whatsapp/src/targets-runtime.ts b/extensions/whatsapp/src/targets-runtime.ts new file mode 100644 index 00000000000..cb308507627 --- /dev/null +++ b/extensions/whatsapp/src/targets-runtime.ts @@ -0,0 +1,180 @@ +import fs from "node:fs"; +import path from "node:path"; +import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution"; +import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { escapeRegExp } from "openclaw/plugin-sdk/text-runtime"; +import { CONFIG_DIR, resolveUserPath } from "openclaw/plugin-sdk/text-runtime"; + +const WHATSAPP_FENCE_PLACEHOLDER = "\x00FENCE"; +const WHATSAPP_INLINE_CODE_PLACEHOLDER = "\x00CODE"; + +export type WebChannel = "web"; + +export function assertWebChannel(input: string): asserts input is WebChannel { + if (input !== "web") { + throw new Error("Web channel must be 'web'"); + } +} + +export function isSelfChatMode( + selfE164: string | null | undefined, + allowFrom?: Array | null, +): boolean { + if (!selfE164) { + return false; + } + if (!Array.isArray(allowFrom) || allowFrom.length === 0) { + return false; + } + const normalizedSelf = normalizeE164(selfE164); + return allowFrom.some((n) => { + if (n === "*") { + return false; + } + try { + return normalizeE164(String(n)) === normalizedSelf; + } catch { + return false; + } + }); +} + +export function toWhatsappJid(number: string): string { + const withoutPrefix = number.replace(/^whatsapp:/i, "").trim(); + if (withoutPrefix.includes("@")) { + return withoutPrefix; + } + const e164 = normalizeE164(withoutPrefix); + const digits = e164.replace(/\D/g, ""); + return `${digits}@s.whatsapp.net`; +} + +export type JidToE164Options = { + authDir?: string; + lidMappingDirs?: string[]; + logMissing?: boolean; +}; + +type LidLookup = { + getPNForLID?: (jid: string) => Promise; +}; + +function resolveLidMappingDirs(params: { opts?: JidToE164Options }): string[] { + const dirs = new Set(); + const addDir = (dir?: string | null) => { + if (!dir) { + return; + } + dirs.add(resolveUserPath(dir)); + }; + addDir(params.opts?.authDir); + for (const dir of params.opts?.lidMappingDirs ?? []) { + addDir(dir); + } + addDir(CONFIG_DIR); + addDir(path.join(CONFIG_DIR, "credentials")); + return [...dirs]; +} + +function readLidReverseMapping(params: { lid: string; opts?: JidToE164Options }): string | null { + const mappingFilename = `lid-mapping-${params.lid}_reverse.json`; + const mappingDirs = resolveLidMappingDirs({ opts: params.opts }); + for (const dir of mappingDirs) { + const mappingPath = path.join(dir, mappingFilename); + try { + const data = fs.readFileSync(mappingPath, "utf8"); + const phone = JSON.parse(data) as string | number | null; + if (phone === null || phone === undefined) { + continue; + } + return normalizeE164(String(phone)); + } catch { + // next location + } + } + return null; +} + +export function jidToE164(jid: string, opts?: JidToE164Options): string | null { + const match = jid.match(/^(\d+)(?::\d+)?@(s\.whatsapp\.net|hosted)$/); + if (match) { + return `+${match[1]}`; + } + + const lidMatch = jid.match(/^(\d+)(?::\d+)?@(lid|hosted\.lid)$/); + if (!lidMatch) { + return null; + } + const phone = readLidReverseMapping({ + lid: lidMatch[1], + opts, + }); + if (phone) { + return phone; + } + const shouldLog = opts?.logMissing ?? shouldLogVerbose(); + if (shouldLog) { + logVerbose(`LID mapping not found for ${lidMatch[1]}; skipping inbound message`); + } + return null; +} + +export async function resolveJidToE164( + jid: string | null | undefined, + opts?: JidToE164Options & { lidLookup?: LidLookup }, +): Promise { + if (!jid) { + return null; + } + const direct = jidToE164(jid, opts); + if (direct) { + return direct; + } + if (!/(@lid|@hosted\.lid)$/.test(jid) || !opts?.lidLookup?.getPNForLID) { + return null; + } + try { + const pnJid = await opts.lidLookup.getPNForLID(jid); + if (!pnJid) { + return null; + } + return jidToE164(pnJid, opts); + } catch (err) { + if (shouldLogVerbose()) { + logVerbose(`LID mapping lookup failed for ${jid}: ${String(err)}`); + } + return null; + } +} + +export function markdownToWhatsApp(text: string): string { + if (!text) { + return text; + } + + const fences: string[] = []; + let result = text.replace(/```[\s\S]*?```/g, (match) => { + fences.push(match); + return `${WHATSAPP_FENCE_PLACEHOLDER}${fences.length - 1}`; + }); + + const inlineCodes: string[] = []; + result = result.replace(/`[^`\n]+`/g, (match) => { + inlineCodes.push(match); + return `${WHATSAPP_INLINE_CODE_PLACEHOLDER}${inlineCodes.length - 1}`; + }); + + result = result.replace(/\*\*(.+?)\*\*/g, "*$1*"); + result = result.replace(/__(.+?)__/g, "*$1*"); + result = result.replace(/~~(.+?)~~/g, "~$1~"); + + result = result.replace( + new RegExp(`${escapeRegExp(WHATSAPP_INLINE_CODE_PLACEHOLDER)}(\\d+)`, "g"), + (_, idx) => inlineCodes[Number(idx)] ?? "", + ); + result = result.replace( + new RegExp(`${escapeRegExp(WHATSAPP_FENCE_PLACEHOLDER)}(\\d+)`, "g"), + (_, idx) => fences[Number(idx)] ?? "", + ); + return result; +} diff --git a/extensions/whatsapp/src/text-runtime.ts b/extensions/whatsapp/src/text-runtime.ts new file mode 100644 index 00000000000..d9731633335 --- /dev/null +++ b/extensions/whatsapp/src/text-runtime.ts @@ -0,0 +1,11 @@ +export * from "openclaw/plugin-sdk/text-runtime"; +export { + assertWebChannel, + isSelfChatMode, + jidToE164, + markdownToWhatsApp, + resolveJidToE164, + toWhatsappJid, + type JidToE164Options, + type WebChannel, +} from "./targets-runtime.js"; diff --git a/extensions/xai/api.ts b/extensions/xai/api.ts index a830f7712db..10787f531e3 100644 --- a/extensions/xai/api.ts +++ b/extensions/xai/api.ts @@ -1,5 +1,7 @@ import { applyModelCompatPatch, + getModelProviderHint, + normalizeNativeXaiModelId, normalizeProviderId, resolveProviderEndpoint, } from "openclaw/plugin-sdk/provider-model-shared"; @@ -19,8 +21,6 @@ export { XAI_DEFAULT_MAX_TOKENS, } from "./model-definitions.js"; export { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; -export { normalizeXaiModelId } from "./model-id.js"; - export const XAI_TOOL_SCHEMA_PROFILE = "xai"; export const HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING = "html-entities"; @@ -47,9 +47,11 @@ function isXaiNativeEndpoint(baseUrl: unknown): boolean { } export function isXaiModelHint(modelId: string): boolean { - return modelId.trim().toLowerCase().startsWith("x-ai/"); + return getModelProviderHint(modelId) === "x-ai"; } +export { normalizeNativeXaiModelId as normalizeXaiModelId }; + function shouldUseXaiResponsesTransport(params: { provider: string; api?: unknown; diff --git a/extensions/zalo/contract-api.ts b/extensions/zalo/contract-api.ts new file mode 100644 index 00000000000..c2b3f52e5e4 --- /dev/null +++ b/extensions/zalo/contract-api.ts @@ -0,0 +1,5 @@ +export { evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy } from "./src/group-access.js"; +export { + collectRuntimeConfigAssignments, + secretTargetRegistryEntries, +} from "./src/secret-contract.js"; diff --git a/extensions/zalo/runtime-api.ts b/extensions/zalo/runtime-api.ts index 90ced0da803..22ae993ca5f 100644 --- a/extensions/zalo/runtime-api.ts +++ b/extensions/zalo/runtime-api.ts @@ -1,4 +1,94 @@ // Private runtime barrel for the bundled Zalo extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/zalo"; +export * from "./api.js"; +export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +export type { OpenClawConfig, GroupPolicy } from "openclaw/plugin-sdk/config-runtime"; +export type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +export type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract"; +export type { + BaseProbeResult, + ChannelAccountSnapshot, + ChannelMessageActionAdapter, + ChannelMessageActionName, + ChannelStatusIssue, +} from "openclaw/plugin-sdk/channel-contract"; +export type { SecretInput } from "openclaw/plugin-sdk/secret-input"; +export type { SenderGroupAccessDecision } from "openclaw/plugin-sdk/group-access"; +export type { ChannelPlugin, PluginRuntime, WizardPrompter } from "openclaw/plugin-sdk/core"; +export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload"; +export { + DEFAULT_ACCOUNT_ID, + buildChannelConfigSchema, + createDedupeCache, + formatPairingApproveHint, + jsonResult, + normalizeAccountId, + readStringParam, + resolveClientIp, +} from "openclaw/plugin-sdk/core"; +export { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + buildSingleChannelSecretPromptState, + mergeAllowFromEntries, + migrateBaseNameToDefaultAccount, + promptSingleChannelSecretInput, + runSingleChannelSecretStep, + setTopLevelChannelDmPolicyWithAllowFrom, +} from "openclaw/plugin-sdk/setup"; +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +} from "openclaw/plugin-sdk/secret-input"; +export { + buildTokenChannelStatusSummary, + PAIRING_APPROVED_MESSAGE, +} from "openclaw/plugin-sdk/channel-status"; +export { buildBaseAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpers"; +export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; +export { + formatAllowFromLowercase, + isNormalizedSenderAllowed, +} from "openclaw/plugin-sdk/allow-from"; +export { addWildcardAllowFrom } from "openclaw/plugin-sdk/setup"; +export { evaluateSenderGroupAccess } from "openclaw/plugin-sdk/group-access"; +export { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; +export { + warnMissingProviderGroupPolicyFallbackOnce, + resolveDefaultGroupPolicy, +} from "openclaw/plugin-sdk/config-runtime"; +export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; +export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; +export { + deliverTextOrMediaReply, + isNumericTargetId, + sendPayloadWithChunkedTextAndMedia, +} from "openclaw/plugin-sdk/reply-payload"; +export { + resolveDirectDmAuthorizationOutcome, + resolveSenderCommandAuthorizationWithRuntime, +} from "openclaw/plugin-sdk/command-auth"; +export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope"; +export { waitForAbortSignal } from "openclaw/plugin-sdk/runtime"; +export { + applyBasicWebhookRequestGuards, + createFixedWindowRateLimiter, + createWebhookAnomalyTracker, + readJsonWebhookBodyOrReject, + registerWebhookTarget, + registerWebhookTargetWithPluginRoute, + resolveWebhookPath, + resolveWebhookTargetWithAuthOrRejectSync, + WEBHOOK_ANOMALY_COUNTER_DEFAULTS, + WEBHOOK_RATE_LIMIT_DEFAULTS, + withResolvedWebhookRequestPipeline, +} from "openclaw/plugin-sdk/webhook-ingress"; +export type { + RegisterWebhookPluginRouteOptions, + RegisterWebhookTargetOptions, +} from "openclaw/plugin-sdk/webhook-ingress"; diff --git a/extensions/zalouser/contract-api.ts b/extensions/zalouser/contract-api.ts new file mode 100644 index 00000000000..9bb33a6b289 --- /dev/null +++ b/extensions/zalouser/contract-api.ts @@ -0,0 +1 @@ +export { collectZalouserSecurityAuditFindings } from "./src/security-audit.js"; diff --git a/extensions/zalouser/runtime-api.ts b/extensions/zalouser/runtime-api.ts index 7d931f2d118..1a99487ad54 100644 --- a/extensions/zalouser/runtime-api.ts +++ b/extensions/zalouser/runtime-api.ts @@ -1,4 +1,60 @@ // Private runtime barrel for the bundled Zalo Personal extension. // Keep this barrel thin and aligned with the local extension surface. -export * from "openclaw/plugin-sdk/zalouser"; +export * from "./api.js"; +export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +export type { + BaseProbeResult, + ChannelAccountSnapshot, + ChannelDirectoryEntry, + ChannelGroupContext, + ChannelMessageActionAdapter, + ChannelStatusIssue, +} from "openclaw/plugin-sdk/channel-contract"; +export type { + OpenClawConfig, + GroupToolPolicyConfig, + MarkdownTableMode, +} from "openclaw/plugin-sdk/config-runtime"; +export type { + PluginRuntime, + AnyAgentTool, + ChannelPlugin, + OpenClawPluginToolContext, +} from "openclaw/plugin-sdk/core"; +export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +export { + DEFAULT_ACCOUNT_ID, + buildChannelConfigSchema, + normalizeAccountId, +} from "openclaw/plugin-sdk/core"; +export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; +export { + isDangerousNameMatchingEnabled, + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "openclaw/plugin-sdk/config-runtime"; +export { + mergeAllowlist, + summarizeMapping, + formatAllowFromLowercase, +} from "openclaw/plugin-sdk/allow-from"; +export { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-inbound"; +export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; +export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { buildBaseAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpers"; +export { resolveSenderCommandAuthorization } from "openclaw/plugin-sdk/command-auth"; +export { + evaluateGroupRouteAccessForPolicy, + resolveSenderScopedGroupPolicy, +} from "openclaw/plugin-sdk/group-access"; +export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; +export { + deliverTextOrMediaReply, + isNumericTargetId, + resolveSendableOutboundReplyParts, + sendPayloadWithChunkedTextAndMedia, + type OutboundReplyPayload, +} from "openclaw/plugin-sdk/reply-payload"; +export { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/browser-support"; diff --git a/extensions/zalouser/src/doctor.ts b/extensions/zalouser/src/doctor.ts index bfc0c0a8e6e..1af708dab5a 100644 --- a/extensions/zalouser/src/doctor.ts +++ b/extensions/zalouser/src/doctor.ts @@ -1,9 +1,7 @@ import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { - collectProviderDangerousNameMatchingScopes, - isZalouserMutableGroupEntry, -} from "openclaw/plugin-sdk/runtime"; +import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime"; +import { isZalouserMutableGroupEntry } from "./security-audit.js"; function asObjectRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) diff --git a/extensions/zalouser/src/security-audit.ts b/extensions/zalouser/src/security-audit.ts index 18212e2e1ab..e3d213de056 100644 --- a/extensions/zalouser/src/security-audit.ts +++ b/extensions/zalouser/src/security-audit.ts @@ -1,7 +1,7 @@ import { isDangerousNameMatchingEnabled } from "../runtime-api.js"; import type { ResolvedZalouserAccount } from "./accounts.js"; -function isZalouserMutableGroupEntry(raw: string): boolean { +export function isZalouserMutableGroupEntry(raw: string): boolean { const text = raw.trim(); if (!text || text === "*") { return false; diff --git a/package.json b/package.json index 38447410a03..a8eb40e6407 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,18 @@ "types": "./dist/plugin-sdk/reply-payload.d.ts", "default": "./dist/plugin-sdk/reply-payload.js" }, + "./plugin-sdk/agent-media-payload": { + "types": "./dist/plugin-sdk/agent-media-payload.d.ts", + "default": "./dist/plugin-sdk/agent-media-payload.js" + }, + "./plugin-sdk/inbound-reply-dispatch": { + "types": "./dist/plugin-sdk/inbound-reply-dispatch.d.ts", + "default": "./dist/plugin-sdk/inbound-reply-dispatch.js" + }, + "./plugin-sdk/inbound-envelope": { + "types": "./dist/plugin-sdk/inbound-envelope.d.ts", + "default": "./dist/plugin-sdk/inbound-envelope.js" + }, "./plugin-sdk/channel-reply-pipeline": { "types": "./dist/plugin-sdk/channel-reply-pipeline.d.ts", "default": "./dist/plugin-sdk/channel-reply-pipeline.js" @@ -195,6 +207,10 @@ "types": "./dist/plugin-sdk/text-runtime.d.ts", "default": "./dist/plugin-sdk/text-runtime.js" }, + "./plugin-sdk/text-chunking": { + "types": "./dist/plugin-sdk/text-chunking.d.ts", + "default": "./dist/plugin-sdk/text-chunking.js" + }, "./plugin-sdk/agent-runtime": { "types": "./dist/plugin-sdk/agent-runtime.d.ts", "default": "./dist/plugin-sdk/agent-runtime.js" @@ -551,6 +567,10 @@ "types": "./dist/plugin-sdk/json-store.d.ts", "default": "./dist/plugin-sdk/json-store.js" }, + "./plugin-sdk/persistent-dedupe": { + "types": "./dist/plugin-sdk/persistent-dedupe.d.ts", + "default": "./dist/plugin-sdk/persistent-dedupe.js" + }, "./plugin-sdk/keyed-async-queue": { "types": "./dist/plugin-sdk/keyed-async-queue.d.ts", "default": "./dist/plugin-sdk/keyed-async-queue.js" @@ -807,6 +827,10 @@ "types": "./dist/plugin-sdk/retry-runtime.d.ts", "default": "./dist/plugin-sdk/retry-runtime.js" }, + "./plugin-sdk/run-command": { + "types": "./dist/plugin-sdk/run-command.d.ts", + "default": "./dist/plugin-sdk/run-command.js" + }, "./plugin-sdk/param-readers": { "types": "./dist/plugin-sdk/param-readers.d.ts", "default": "./dist/plugin-sdk/param-readers.js" @@ -883,6 +907,10 @@ "types": "./dist/plugin-sdk/webhook-ingress.d.ts", "default": "./dist/plugin-sdk/webhook-ingress.js" }, + "./plugin-sdk/webhook-targets": { + "types": "./dist/plugin-sdk/webhook-targets.d.ts", + "default": "./dist/plugin-sdk/webhook-targets.js" + }, "./plugin-sdk/webhook-request-guards": { "types": "./dist/plugin-sdk/webhook-request-guards.d.ts", "default": "./dist/plugin-sdk/webhook-request-guards.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 3f74d6b9283..13006c74a74 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -20,6 +20,9 @@ "reply-reference", "reply-chunking", "reply-payload", + "agent-media-payload", + "inbound-reply-dispatch", + "inbound-envelope", "channel-reply-pipeline", "channel-runtime", "interactive-runtime", @@ -38,6 +41,7 @@ "thread-bindings-runtime", "together", "text-runtime", + "text-chunking", "agent-runtime", "speech-runtime", "speech-core", @@ -127,6 +131,7 @@ "request-url", "runtime-store", "json-store", + "persistent-dedupe", "keyed-async-queue", "line", "line-core", @@ -191,6 +196,7 @@ "provider-web-fetch", "provider-web-search", "retry-runtime", + "run-command", "param-readers", "provider-zai-endpoint", "secret-input", @@ -210,6 +216,7 @@ "vllm", "xai", "webhook-ingress", + "webhook-targets", "webhook-request-guards", "webhook-path", "web-media", diff --git a/scripts/lib/plugin-sdk-facades.mjs b/scripts/lib/plugin-sdk-facades.mjs index 584a4729039..e6df0da2bf7 100644 --- a/scripts/lib/plugin-sdk-facades.mjs +++ b/scripts/lib/plugin-sdk-facades.mjs @@ -876,16 +876,6 @@ export const GENERATED_PLUGIN_SDK_FACADES = [ "ZAI_GLOBAL_BASE_URL", ], }, - { - subpath: "whatsapp-targets", - source: pluginSource("whatsapp", "targets.js"), - exports: ["isWhatsAppGroupJid", "isWhatsAppUserTarget", "normalizeWhatsAppTarget"], - directExports: { - isWhatsAppGroupJid: "./whatsapp-targets-shared.js", - isWhatsAppUserTarget: "./whatsapp-targets-shared.js", - normalizeWhatsAppTarget: "./whatsapp-targets-shared.js", - }, - }, { subpath: "whatsapp-surface", source: pluginSource("whatsapp", "api.js"), diff --git a/src/acp/commands.ts b/src/acp/commands.ts index 6bd8e85a819..a31a3c01ffe 100644 --- a/src/acp/commands.ts +++ b/src/acp/commands.ts @@ -1,40 +1,49 @@ import type { AvailableCommand } from "@agentclientprotocol/sdk"; +import { getChatCommands } from "../auto-reply/commands-registry.data.js"; + +const BASE_AVAILABLE_COMMANDS: AvailableCommand[] = [ + { name: "help", description: "Show help and common commands." }, + { name: "commands", description: "List available commands." }, + { name: "status", description: "Show current status." }, + { + name: "context", + description: "Explain context usage (list|detail|json).", + input: { hint: "list | detail | json" }, + }, + { name: "whoami", description: "Show sender id (alias: /id)." }, + { name: "id", description: "Alias for /whoami." }, + { name: "subagents", description: "List or manage sub-agents." }, + { name: "config", description: "Read or write config (owner-only)." }, + { name: "debug", description: "Set runtime-only overrides (owner-only)." }, + { name: "usage", description: "Toggle usage footer (off|tokens|full)." }, + { name: "stop", description: "Stop the current run." }, + { name: "restart", description: "Restart the gateway (if enabled)." }, + { name: "activation", description: "Set group activation (mention|always)." }, + { name: "send", description: "Set send mode (on|off|inherit)." }, + { name: "reset", description: "Reset the session (/new)." }, + { name: "new", description: "Reset the session (/reset)." }, + { + name: "think", + description: "Set thinking level (off|minimal|low|medium|high|xhigh).", + }, + { name: "verbose", description: "Set verbose mode (on|full|off)." }, + { name: "reasoning", description: "Toggle reasoning output (on|off|stream)." }, + { name: "elevated", description: "Toggle elevated mode (on|off)." }, + { name: "model", description: "Select a model (list|status|)." }, + { name: "queue", description: "Adjust queue mode and options." }, + { name: "bash", description: "Run a host command (if enabled)." }, + { name: "compact", description: "Compact the session history." }, +]; + +function listDockAvailableCommands(): AvailableCommand[] { + return getChatCommands() + .filter((command) => command.key.startsWith("dock:")) + .map((command) => ({ + name: command.textAliases[0]?.replace(/^\//, "").trim() || command.key, + description: command.description, + })); +} export function getAvailableCommands(): AvailableCommand[] { - return [ - { name: "help", description: "Show help and common commands." }, - { name: "commands", description: "List available commands." }, - { name: "status", description: "Show current status." }, - { - name: "context", - description: "Explain context usage (list|detail|json).", - input: { hint: "list | detail | json" }, - }, - { name: "whoami", description: "Show sender id (alias: /id)." }, - { name: "id", description: "Alias for /whoami." }, - { name: "subagents", description: "List or manage sub-agents." }, - { name: "config", description: "Read or write config (owner-only)." }, - { name: "debug", description: "Set runtime-only overrides (owner-only)." }, - { name: "usage", description: "Toggle usage footer (off|tokens|full)." }, - { name: "stop", description: "Stop the current run." }, - { name: "restart", description: "Restart the gateway (if enabled)." }, - { name: "dock-telegram", description: "Route replies to Telegram." }, - { name: "dock-discord", description: "Route replies to Discord." }, - { name: "dock-slack", description: "Route replies to Slack." }, - { name: "activation", description: "Set group activation (mention|always)." }, - { name: "send", description: "Set send mode (on|off|inherit)." }, - { name: "reset", description: "Reset the session (/new)." }, - { name: "new", description: "Reset the session (/reset)." }, - { - name: "think", - description: "Set thinking level (off|minimal|low|medium|high|xhigh).", - }, - { name: "verbose", description: "Set verbose mode (on|full|off)." }, - { name: "reasoning", description: "Toggle reasoning output (on|off|stream)." }, - { name: "elevated", description: "Toggle elevated mode (on|off)." }, - { name: "model", description: "Select a model (list|status|)." }, - { name: "queue", description: "Adjust queue mode and options." }, - { name: "bash", description: "Run a host command (if enabled)." }, - { name: "compact", description: "Compact the session history." }, - ]; + return [...BASE_AVAILABLE_COMMANDS, ...listDockAvailableCommands()]; } diff --git a/src/acp/conversation-id.ts b/src/acp/conversation-id.ts index 639d6113463..0c84bc9fed8 100644 --- a/src/acp/conversation-id.ts +++ b/src/acp/conversation-id.ts @@ -1,9 +1,3 @@ -export type ParsedTelegramTopicConversation = { - chatId: string; - topicId: string; - canonicalConversationId: string; -}; - export function normalizeConversationText(value: unknown): string { if (typeof value === "string") { return value.trim(); @@ -13,68 +7,3 @@ export function normalizeConversationText(value: unknown): string { } return ""; } - -export function parseTelegramChatIdFromTarget(raw: unknown): string | undefined { - const text = normalizeConversationText(raw); - if (!text) { - return undefined; - } - const match = text.match(/^telegram:(-?\d+)$/); - if (!match?.[1]) { - return undefined; - } - return match[1]; -} - -export function buildTelegramTopicConversationId(params: { - chatId: string; - topicId: string; -}): string | null { - const chatId = params.chatId.trim(); - const topicId = params.topicId.trim(); - if (!/^-?\d+$/.test(chatId) || !/^\d+$/.test(topicId)) { - return null; - } - return `${chatId}:topic:${topicId}`; -} - -export function parseTelegramTopicConversation(params: { - conversationId: string; - parentConversationId?: string; -}): ParsedTelegramTopicConversation | null { - const conversation = params.conversationId.trim(); - const directMatch = conversation.match(/^(-?\d+):topic:(\d+)$/i); - if (directMatch?.[1] && directMatch[2]) { - const canonicalConversationId = buildTelegramTopicConversationId({ - chatId: directMatch[1], - topicId: directMatch[2], - }); - if (!canonicalConversationId) { - return null; - } - return { - chatId: directMatch[1], - topicId: directMatch[2], - canonicalConversationId, - }; - } - if (!/^\d+$/.test(conversation)) { - return null; - } - const parent = params.parentConversationId?.trim(); - if (!parent || !/^-?\d+$/.test(parent)) { - return null; - } - const canonicalConversationId = buildTelegramTopicConversationId({ - chatId: parent, - topicId: conversation, - }); - if (!canonicalConversationId) { - return null; - } - return { - chatId: parent, - topicId: conversation, - canonicalConversationId, - }; -} diff --git a/src/agents/command/delivery.ts b/src/agents/command/delivery.ts index 4b075c3ff9f..16919fb3f34 100644 --- a/src/agents/command/delivery.ts +++ b/src/agents/command/delivery.ts @@ -87,6 +87,7 @@ export function normalizeAgentCommandReplyPayloads(params: { if (!channel) { return payloads as ReplyPayload[]; } + const deliveryPlugin = getChannelPlugin(channel); const sessionKey = params.outboundSession?.key ?? params.opts.sessionKey; const agentId = @@ -112,6 +113,14 @@ export function normalizeAgentCommandReplyPayloads(params: { } const responsePrefixContext = replyPrefix.responsePrefixContextProvider(); const applyChannelTransforms = params.applyChannelTransforms ?? true; + const transformReplyPayload = deliveryPlugin?.messaging?.transformReplyPayload + ? (payload: ReplyPayload) => + deliveryPlugin.messaging?.transformReplyPayload?.({ + payload, + cfg: params.cfg, + accountId: params.accountId, + }) ?? payload + : undefined; const normalizedPayloads: ReplyPayload[] = []; for (const payload of payloads) { @@ -119,6 +128,7 @@ export function normalizeAgentCommandReplyPayloads(params: { responsePrefix: replyPrefix.responsePrefix, applyChannelTransforms, responsePrefixContext, + transformReplyPayload, }); if (normalized) { normalizedPayloads.push(normalized); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 5b72575238c..2cf8a690c23 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -6,8 +6,10 @@ import { toAgentModelListLike, } from "../config/model-input.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { normalizeGoogleModelId } from "../plugin-sdk/google-model-id.js"; -import { normalizeXaiModelId } from "../plugin-sdk/xai-model-id.js"; +import { + normalizeGooglePreviewModelId, + normalizeNativeXaiModelId, +} from "../plugin-sdk/provider-model-shared.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { resolveAgentConfig, @@ -142,7 +144,7 @@ function normalizeProviderModelId(provider: string, model: string): string { return normalizeAnthropicModelId(model); } if (provider === "google" || provider === "google-vertex") { - return normalizeGoogleModelId(model); + return normalizeGooglePreviewModelId(model); } if (provider === "openai") { return model; @@ -151,7 +153,7 @@ function normalizeProviderModelId(provider: string, model: string): string { return model.includes("/") ? model : `openrouter/${model}`; } if (provider === "xai") { - return normalizeXaiModelId(model); + return normalizeNativeXaiModelId(model); } if (provider === "vercel-ai-gateway" && !model.includes("/")) { // Allow Vercel-specific Claude refs without an upstream prefix. diff --git a/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts b/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts index 40a6c376045..22bfb6389ef 100644 --- a/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts @@ -1,7 +1,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { streamSimple } from "@mariozechner/pi-ai"; -import { isXaiModelHint } from "../../../extensions/xai/api.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; +import { isProxyReasoningUnsupportedModelHint } from "../../plugin-sdk/provider-model-shared.js"; import { resolveProviderRequestPolicyConfig } from "../provider-request-config.js"; import { applyAnthropicEphemeralCacheControlMarkers } from "./anthropic-cache-control-payload.js"; import { isOpenRouterAnthropicModelRef } from "./anthropic-family-cache-semantics.js"; @@ -103,7 +103,7 @@ export function createOpenRouterWrapper( } export function isProxyReasoningUnsupported(modelId: string): boolean { - return isXaiModelHint(modelId); + return isProxyReasoningUnsupportedModelHint(modelId); } export function createKilocodeWrapper( diff --git a/src/auto-reply/reply/discord-parent-channel.ts b/src/auto-reply/reply/discord-parent-channel.ts deleted file mode 100644 index 877c4593ea7..00000000000 --- a/src/auto-reply/reply/discord-parent-channel.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { normalizeConversationText } from "../../acp/conversation-id.js"; -import { parseAgentSessionKey } from "../../routing/session-key.js"; - -export function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined { - const sessionKey = normalizeConversationText(raw); - if (!sessionKey) { - return undefined; - } - const scoped = parseAgentSessionKey(sessionKey)?.rest ?? sessionKey.toLowerCase(); - const match = scoped.match(/(?:^|:)channel:([^:]+)$/); - if (!match?.[1]) { - return undefined; - } - return match[1]; -} diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index 81afdc6c62f..192cfa01703 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -9,7 +9,6 @@ import { stripSilentToken, } from "../tokens.js"; import type { ReplyPayload } from "../types.js"; -import { hasLineDirectives, parseLineDirectives } from "./line-directives.js"; import { resolveResponsePrefixTemplate, type ResponsePrefixContext, @@ -25,6 +24,7 @@ export type NormalizeReplyOptions = { onHeartbeatStrip?: () => void; stripHeartbeat?: boolean; silentToken?: string; + transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null; onSkip?: (reason: NormalizeReplySkipReason) => void; }; @@ -94,10 +94,9 @@ export function normalizeReplyPayload( return null; } - // Parse LINE-specific directives from text (quick_replies, location, confirm, buttons) let enrichedPayload: ReplyPayload = { ...payload, text }; - if (applyChannelTransforms && text && hasLineDirectives(text)) { - enrichedPayload = parseLineDirectives(enrichedPayload); + if (applyChannelTransforms && opts.transformReplyPayload) { + enrichedPayload = opts.transformReplyPayload(enrichedPayload) ?? enrichedPayload; text = enrichedPayload.text; } diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index e0cae6a9dd9..07ebc5512aa 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -44,6 +44,7 @@ function getHumanDelay(config: HumanDelayConfig | undefined): number { export type ReplyDispatcherOptions = { deliver: ReplyDispatchDeliverer; responsePrefix?: string; + transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null; /** Static context for response prefix template interpolation. */ responsePrefixContext?: ResponsePrefixContext; /** Dynamic context provider for response prefix template interpolation. @@ -86,7 +87,11 @@ export type ReplyDispatcher = { type NormalizeReplyPayloadInternalOptions = Pick< ReplyDispatcherOptions, - "responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip" + | "responsePrefix" + | "responsePrefixContext" + | "responsePrefixContextProvider" + | "onHeartbeatStrip" + | "transformReplyPayload" > & { onSkip?: (reason: NormalizeReplySkipReason) => void; }; @@ -102,6 +107,7 @@ function normalizeReplyPayloadInternal( responsePrefix: opts.responsePrefix, responsePrefixContext: prefixContext, onHeartbeatStrip: opts.onHeartbeatStrip, + transformReplyPayload: opts.transformReplyPayload, onSkip: opts.onSkip, }); } @@ -138,6 +144,7 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis responsePrefix: options.responsePrefix, responsePrefixContext: options.responsePrefixContext, responsePrefixContextProvider: options.responsePrefixContextProvider, + transformReplyPayload: options.transformReplyPayload, onHeartbeatStrip: options.onHeartbeatStrip, onSkip: (reason) => options.onSkip?.(payload, { kind, reason }), }); diff --git a/src/auto-reply/reply/reply-payloads-dedupe.ts b/src/auto-reply/reply/reply-payloads-dedupe.ts index 0e6a0dae5ba..f38be40e726 100644 --- a/src/auto-reply/reply/reply-payloads-dedupe.ts +++ b/src/auto-reply/reply/reply-payloads-dedupe.ts @@ -60,10 +60,6 @@ export function filterMessagingToolMediaDuplicates(params: { }); } -const PROVIDER_ALIAS_MAP: Record = { - lark: "feishu", -}; - function normalizeProviderForComparison(value?: string): string | undefined { const trimmed = value?.trim(); if (!trimmed) { @@ -74,7 +70,7 @@ function normalizeProviderForComparison(value?: string): string | undefined { if (normalizedChannel) { return normalizedChannel; } - return PROVIDER_ALIAS_MAP[lowered] ?? lowered; + return lowered; } function normalizeThreadIdForComparison(value?: string): string | undefined { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 0d27c7592bb..a107e8d6d3a 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -101,6 +101,14 @@ export async function routeReply(params: RouteReplyParams): Promise + plugin.messaging?.transformReplyPayload?.({ + payload: nextPayload, + cfg, + accountId, + }) ?? nextPayload + : undefined, }); if (!normalized) { return { ok: true }; diff --git a/src/auto-reply/reply/slack-directives.ts b/src/auto-reply/reply/slack-directives.ts deleted file mode 100644 index 84170cc2326..00000000000 --- a/src/auto-reply/reply/slack-directives.ts +++ /dev/null @@ -1,276 +0,0 @@ -import type { ReplyPayload } from "../types.js"; - -const SLACK_BUTTON_MAX_ITEMS = 5; -const SLACK_SELECT_MAX_ITEMS = 100; -const SLACK_DIRECTIVE_RE = /\[\[(slack_buttons|slack_select):\s*([^\]]+)\]\]/gi; -const SLACK_OPTIONS_LINE_RE = /^\s*Options:\s*(.+?)\s*\.?\s*$/i; -const SLACK_AUTO_SELECT_MAX_ITEMS = 12; -const SLACK_SIMPLE_OPTION_RE = /^[a-z0-9][a-z0-9 _+/-]{0,31}$/i; - -type SlackChoice = { - label: string; - value: string; - style?: "primary" | "secondary" | "success" | "danger"; -}; - -function parseChoice(raw: string, options?: { allowStyle?: boolean }): SlackChoice | null { - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - const delimiter = trimmed.indexOf(":"); - if (delimiter === -1) { - return { - label: trimmed, - value: trimmed, - }; - } - const label = trimmed.slice(0, delimiter).trim(); - let value = trimmed.slice(delimiter + 1).trim(); - if (!label || !value) { - return null; - } - let style: SlackChoice["style"]; - if (options?.allowStyle) { - // Trailing style keywords are reserved for Slack button styling, so button - // values that need to end with one of these tokens must use a different suffix. - const styleDelimiter = value.lastIndexOf(":"); - if (styleDelimiter !== -1) { - const maybeStyle = value - .slice(styleDelimiter + 1) - .trim() - .toLowerCase(); - if ( - maybeStyle === "primary" || - maybeStyle === "secondary" || - maybeStyle === "success" || - maybeStyle === "danger" - ) { - const unstyledValue = value.slice(0, styleDelimiter).trim(); - if (unstyledValue) { - value = unstyledValue; - style = maybeStyle; - } - } - } - } - return style ? { label, value, style } : { label, value }; -} - -function parseChoices( - raw: string, - maxItems: number, - options?: { allowStyle?: boolean }, -): SlackChoice[] { - return raw - .split(",") - .map((entry) => parseChoice(entry, options)) - .filter((entry): entry is SlackChoice => Boolean(entry)) - .slice(0, maxItems); -} - -function buildTextBlock( - text: string, -): NonNullable["blocks"][number] | null { - const trimmed = text.trim(); - if (!trimmed) { - return null; - } - return { type: "text", text: trimmed }; -} - -function buildButtonsBlock( - raw: string, -): NonNullable["blocks"][number] | null { - const choices = parseChoices(raw, SLACK_BUTTON_MAX_ITEMS, { allowStyle: true }); - if (choices.length === 0) { - return null; - } - return { - type: "buttons", - buttons: choices.map((choice) => ({ - label: choice.label, - value: choice.value, - ...(choice.style ? { style: choice.style } : {}), - })), - }; -} - -function buildSelectBlock( - raw: string, -): NonNullable["blocks"][number] | null { - const parts = raw - .split("|") - .map((entry) => entry.trim()) - .filter(Boolean); - if (parts.length === 0) { - return null; - } - const [first, second] = parts; - const placeholder = parts.length >= 2 ? first : "Choose an option"; - const choices = parseChoices(parts.length >= 2 ? second : first, SLACK_SELECT_MAX_ITEMS); - if (choices.length === 0) { - return null; - } - return { - type: "select", - placeholder, - options: choices, - }; -} - -export function hasSlackDirectives(text: string): boolean { - SLACK_DIRECTIVE_RE.lastIndex = 0; - return SLACK_DIRECTIVE_RE.test(text); -} - -export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload { - const text = payload.text; - if (!text) { - return payload; - } - - const generatedBlocks: NonNullable["blocks"] = []; - const visibleTextParts: string[] = []; - let cursor = 0; - let matchedDirective = false; - let generatedInteractiveBlock = false; - SLACK_DIRECTIVE_RE.lastIndex = 0; - - for (const match of text.matchAll(SLACK_DIRECTIVE_RE)) { - matchedDirective = true; - const matchText = match[0]; - const directiveType = match[1]; - const body = match[2]; - const index = match.index ?? 0; - const precedingText = text.slice(cursor, index); - visibleTextParts.push(precedingText); - const section = buildTextBlock(precedingText); - if (section) { - generatedBlocks.push(section); - } - const block = - directiveType.toLowerCase() === "slack_buttons" - ? buildButtonsBlock(body) - : buildSelectBlock(body); - if (block) { - generatedInteractiveBlock = true; - generatedBlocks.push(block); - } - cursor = index + matchText.length; - } - - const trailingText = text.slice(cursor); - visibleTextParts.push(trailingText); - const trailingSection = buildTextBlock(trailingText); - if (trailingSection) { - generatedBlocks.push(trailingSection); - } - const cleanedText = visibleTextParts.join(""); - - if (!matchedDirective || !generatedInteractiveBlock) { - return payload; - } - - return { - ...payload, - text: cleanedText.trim() || undefined, - interactive: { - blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks], - }, - }; -} - -function hasSlackBlocks(payload: ReplyPayload): boolean { - const blocks = (payload.channelData?.slack as { blocks?: unknown } | undefined)?.blocks; - if (typeof blocks === "string") { - return blocks.trim().length > 0; - } - return Array.isArray(blocks) && blocks.length > 0; -} - -function parseSimpleSlackOptions(raw: string): SlackChoice[] | null { - const entries = raw - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean); - if (entries.length < 2 || entries.length > SLACK_AUTO_SELECT_MAX_ITEMS) { - return null; - } - if (!entries.every((entry) => SLACK_SIMPLE_OPTION_RE.test(entry))) { - return null; - } - const deduped = new Set(entries.map((entry) => entry.toLowerCase())); - if (deduped.size !== entries.length) { - return null; - } - return entries.map((entry) => ({ - label: entry, - value: entry, - })); -} - -export function parseSlackOptionsLine(payload: ReplyPayload): ReplyPayload { - const text = payload.text; - if (!text || payload.interactive?.blocks?.length || hasSlackBlocks(payload)) { - return payload; - } - - const lines = text.split("\n"); - const lastNonEmptyIndex = [...lines.keys()].toReversed().find((index) => lines[index]?.trim()); - if (lastNonEmptyIndex == null) { - return payload; - } - - const optionsLine = lines[lastNonEmptyIndex] ?? ""; - const match = optionsLine.match(SLACK_OPTIONS_LINE_RE); - if (!match) { - return payload; - } - - const choices = parseSimpleSlackOptions(match[1] ?? ""); - if (!choices) { - return payload; - } - - const bodyText = lines - .filter((_, index) => index !== lastNonEmptyIndex) - .join("\n") - .trim(); - const generatedBlocks: NonNullable["blocks"] = []; - const bodyBlock = buildTextBlock(bodyText); - if (bodyBlock) { - generatedBlocks.push(bodyBlock); - } - generatedBlocks.push( - choices.length <= SLACK_BUTTON_MAX_ITEMS - ? { - type: "buttons", - buttons: choices, - } - : { - type: "select", - placeholder: "Choose an option", - options: choices, - }, - ); - - return { - ...payload, - text: bodyText || undefined, - interactive: { - blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks], - }, - }; -} - -export function compileSlackInteractiveReplies(payload: ReplyPayload): ReplyPayload { - const text = payload.text; - if (!text) { - return payload; - } - if (hasSlackDirectives(text)) { - return parseSlackDirectives(payload); - } - return parseSlackOptionsLine(payload); -} diff --git a/src/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts index 3d3dec1738f..5feca6698c6 100644 --- a/src/auto-reply/reply/stage-sandbox-media.ts +++ b/src/auto-reply/reply/stage-sandbox-media.ts @@ -9,10 +9,8 @@ import { logVerbose } from "../../globals.js"; import { copyFileWithinRoot, SafeOpenError } from "../../infra/fs-safe.js"; import { normalizeScpRemoteHost, normalizeScpRemotePath } from "../../infra/scp-host.js"; import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; -import { - isInboundPathAllowed, - resolveIMessageRemoteAttachmentRoots, -} from "../../media/inbound-path-policy.js"; +import { resolveChannelRemoteInboundAttachmentRoots } from "../../media/channel-inbound-roots.js"; +import { isInboundPathAllowed } from "../../media/inbound-path-policy.js"; import { getMediaDir, MEDIA_MAX_BYTES } from "../../media/store.js"; import { CONFIG_DIR } from "../../utils.js"; import type { MsgContext, TemplateContext } from "../templating.js"; @@ -49,10 +47,7 @@ export async function stageSandboxMedia(params: { } await fs.mkdir(effectiveWorkspaceDir, { recursive: true }); - const remoteAttachmentRoots = resolveIMessageRemoteAttachmentRoots({ - cfg, - accountId: ctx.AccountId, - }); + const remoteAttachmentRoots = resolveChannelRemoteInboundAttachmentRoots({ cfg, ctx }) ?? []; const usedNames = new Set(); const staged = new Map(); // absolute source -> relative sandbox path diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 09d30bad404..1d3f0680088 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -1,27 +1,8 @@ -import fs from "node:fs"; -import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveOAuthDir } from "../config/paths.js"; -import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { listBundledChannelPlugins } from "./plugins/bundled.js"; const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); -const CHANNEL_ENV_PREFIXES = [ - ["BLUEBUBBLES_", "bluebubbles"], - ["DISCORD_", "discord"], - ["GOOGLECHAT_", "googlechat"], - ["IRC_", "irc"], - ["LINE_", "line"], - ["MATRIX_", "matrix"], - ["MSTEAMS_", "msteams"], - ["SIGNAL_", "signal"], - ["SLACK_", "slack"], - ["TELEGRAM_", "telegram"], - ["WHATSAPP_", "whatsapp"], - ["ZALOUSER_", "zalouser"], - ["ZALO_", "zalo"], -] as const; - function hasNonEmptyString(value: unknown): boolean { return typeof value === "string" && value.trim().length > 0; } @@ -37,30 +18,11 @@ export function hasMeaningfulChannelConfig(value: unknown): boolean { return Object.keys(value).some((key) => key !== "enabled"); } -function hasWhatsAppAuthState(env: NodeJS.ProcessEnv): boolean { - try { - const oauthDir = resolveOAuthDir(env); - const legacyCreds = path.join(oauthDir, "creds.json"); - if (fs.existsSync(legacyCreds)) { - return true; - } - - const accountsRoot = path.join(oauthDir, "whatsapp"); - const defaultCreds = path.join(accountsRoot, DEFAULT_ACCOUNT_ID, "creds.json"); - if (fs.existsSync(defaultCreds)) { - return true; - } - - const entries = fs.readdirSync(accountsRoot, { withFileTypes: true }); - return entries.some((entry) => { - if (!entry.isDirectory()) { - return false; - } - return fs.existsSync(path.join(accountsRoot, entry.name, "creds.json")); - }); - } catch { - return false; - } +function listConfiguredChannelEnvPrefixes(): Array<[prefix: string, channelId: string]> { + return listBundledChannelPlugins().map((plugin) => [ + `${plugin.id.replace(/[^a-z0-9]+/gi, "_").toUpperCase()}_`, + plugin.id, + ]); } export function listPotentialConfiguredChannelIds( @@ -68,6 +30,7 @@ export function listPotentialConfiguredChannelIds( env: NodeJS.ProcessEnv = process.env, ): string[] { const configuredChannelIds = new Set(); + const channelEnvPrefixes = listConfiguredChannelEnvPrefixes(); const channels = isRecord(cfg.channels) ? cfg.channels : null; if (channels) { for (const [key, value] of Object.entries(channels)) { @@ -84,34 +47,33 @@ export function listPotentialConfiguredChannelIds( if (!hasNonEmptyString(value)) { continue; } - for (const [prefix, channelId] of CHANNEL_ENV_PREFIXES) { + for (const [prefix, channelId] of channelEnvPrefixes) { if (key.startsWith(prefix)) { configuredChannelIds.add(channelId); } } - if (key === "TELEGRAM_BOT_TOKEN") { - configuredChannelIds.add("telegram"); - } } - if (hasWhatsAppAuthState(env)) { - configuredChannelIds.add("whatsapp"); + for (const plugin of listBundledChannelPlugins()) { + if (plugin.config?.hasPersistedAuthState?.({ cfg, env })) { + configuredChannelIds.add(plugin.id); + } } return [...configuredChannelIds]; } -function hasEnvConfiguredChannel(env: NodeJS.ProcessEnv): boolean { +function hasEnvConfiguredChannel(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { + const channelEnvPrefixes = listConfiguredChannelEnvPrefixes(); for (const [key, value] of Object.entries(env)) { if (!hasNonEmptyString(value)) { continue; } - if ( - CHANNEL_ENV_PREFIXES.some(([prefix]) => key.startsWith(prefix)) || - key === "TELEGRAM_BOT_TOKEN" - ) { + if (channelEnvPrefixes.some(([prefix]) => key.startsWith(prefix))) { return true; } } - return hasWhatsAppAuthState(env); + return listBundledChannelPlugins().some((plugin) => + Boolean(plugin.config?.hasPersistedAuthState?.({ cfg, env })), + ); } export function hasPotentialConfiguredChannels( @@ -129,5 +91,5 @@ export function hasPotentialConfiguredChannels( } } } - return hasEnvConfiguredChannel(env); + return hasEnvConfiguredChannel(cfg ?? {}, env); } diff --git a/src/channels/model-overrides.ts b/src/channels/model-overrides.ts index 0d526d7cdb7..629875aec55 100644 --- a/src/channels/model-overrides.ts +++ b/src/channels/model-overrides.ts @@ -1,5 +1,4 @@ import type { OpenClawConfig } from "../config/config.js"; -import { parseRawSessionConversationRef } from "../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { buildChannelKeyCandidates, @@ -8,6 +7,7 @@ import { type ChannelMatchSource, } from "./channel-config.js"; import { normalizeChatType } from "./chat-type.js"; +import { getChannelPlugin } from "./plugins/registry.js"; import { resolveSessionConversation, resolveSessionConversationRef, @@ -58,10 +58,14 @@ function buildChannelCandidates( normalizeMessageChannel(params.channel ?? "") ?? params.channel?.trim().toLowerCase(); const groupId = params.groupId?.trim(); const sessionConversation = resolveSessionConversationRef(params.parentSessionKey); - const feishuParentFallbacks = resolveFeishuParentSessionFallbackCandidates({ - channel: normalizedChannel, - parentSessionKey: params.parentSessionKey, - }); + const parentOverrideFallbacks = + (normalizedChannel + ? getChannelPlugin( + normalizedChannel, + )?.conversationBindings?.buildModelOverrideParentCandidates?.({ + parentConversationId: sessionConversation?.rawId, + }) + : null) ?? []; const groupConversationKind = normalizeChatType(params.groupChatType ?? undefined) === "channel" ? "channel" @@ -86,7 +90,7 @@ function buildChannelCandidates( sessionConversation?.rawId, ...(groupConversation?.parentConversationCandidates ?? []), ...(sessionConversation?.parentConversationCandidates ?? []), - ...feishuParentFallbacks, + ...parentOverrideFallbacks, ), parentKeys: buildChannelKeyCandidates( groupChannel, @@ -99,43 +103,6 @@ function buildChannelCandidates( }; } -function resolveFeishuParentSessionFallbackCandidates(params: { - channel?: string; - parentSessionKey?: string | null; -}): string[] { - if (params.channel !== "feishu") { - return []; - } - const rawId = parseRawSessionConversationRef(params.parentSessionKey)?.rawId?.trim(); - if (!rawId) { - return []; - } - const topicSenderMatch = rawId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i); - if (topicSenderMatch) { - const chatId = topicSenderMatch[1]?.trim().toLowerCase(); - const topicId = topicSenderMatch[2]?.trim().toLowerCase(); - if (chatId && topicId) { - return [`${chatId}:topic:${topicId}`, chatId]; - } - return []; - } - const topicMatch = rawId.match(/^(.+):topic:([^:]+)$/i); - if (topicMatch) { - const chatId = topicMatch[1]?.trim().toLowerCase(); - const topicId = topicMatch[2]?.trim().toLowerCase(); - if (chatId && topicId) { - return [chatId]; - } - return []; - } - const senderMatch = rawId.match(/^(.+):sender:([^:]+)$/i); - if (senderMatch) { - const chatId = senderMatch[1]?.trim().toLowerCase(); - return chatId ? [chatId] : []; - } - return []; -} - export function resolveChannelModelOverride( params: ChannelModelOverrideParams, ): ChannelModelOverride | null { diff --git a/src/channels/plugins/contract-surfaces.ts b/src/channels/plugins/contract-surfaces.ts new file mode 100644 index 00000000000..b828ce36b0d --- /dev/null +++ b/src/channels/plugins/contract-surfaces.ts @@ -0,0 +1,91 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createJiti } from "jiti"; +import { discoverOpenClawPlugins } from "../../plugins/discovery.js"; +import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; +import { + buildPluginLoaderAliasMap, + buildPluginLoaderJitiOptions, + shouldPreferNativeJiti, +} from "../../plugins/sdk-alias.js"; + +const CONTRACT_BASENAME = "contract-api.ts"; + +let cachedSurfaces: unknown[] | null = null; +let cachedSurfaceEntries: Array<{ + pluginId: string; + surface: unknown; +}> | null = null; + +function createModuleLoader() { + const jitiLoaders = new Map>(); + return (modulePath: string) => { + const tryNative = shouldPreferNativeJiti(modulePath); + const aliasMap = buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url); + const cacheKey = JSON.stringify({ + tryNative, + aliasMap: Object.entries(aliasMap).toSorted(([a], [b]) => a.localeCompare(b)), + }); + const cached = jitiLoaders.get(cacheKey); + if (cached) { + return cached; + } + const loader = createJiti(import.meta.url, { + ...buildPluginLoaderJitiOptions(aliasMap), + tryNative, + }); + jitiLoaders.set(cacheKey, loader); + return loader; + }; +} + +const loadModule = createModuleLoader(); + +function loadBundledChannelContractSurfaces(): unknown[] { + return loadBundledChannelContractSurfaceEntries().map((entry) => entry.surface); +} + +function loadBundledChannelContractSurfaceEntries(): Array<{ + pluginId: string; + surface: unknown; +}> { + const discovery = discoverOpenClawPlugins({ cache: false }); + const manifestRegistry = loadPluginManifestRegistry({ + cache: false, + config: {}, + candidates: discovery.candidates, + diagnostics: discovery.diagnostics, + }); + const surfaces: Array<{ pluginId: string; surface: unknown }> = []; + for (const manifest of manifestRegistry.plugins) { + if (manifest.origin !== "bundled" || manifest.channels.length === 0) { + continue; + } + const modulePath = path.join(manifest.rootDir, CONTRACT_BASENAME); + if (!fs.existsSync(modulePath)) { + continue; + } + try { + surfaces.push({ + pluginId: manifest.id, + surface: loadModule(modulePath)(modulePath), + }); + } catch { + continue; + } + } + return surfaces; +} + +export function getBundledChannelContractSurfaces(): unknown[] { + cachedSurfaces ??= loadBundledChannelContractSurfaces(); + return cachedSurfaces; +} + +export function getBundledChannelContractSurfaceEntries(): Array<{ + pluginId: string; + surface: unknown; +}> { + cachedSurfaceEntries ??= loadBundledChannelContractSurfaceEntries(); + return cachedSurfaceEntries; +} diff --git a/src/channels/plugins/contracts/registry-session-binding.ts b/src/channels/plugins/contracts/registry-session-binding.ts index acf6b3d2a1e..1e479c90dfd 100644 --- a/src/channels/plugins/contracts/registry-session-binding.ts +++ b/src/channels/plugins/contracts/registry-session-binding.ts @@ -23,14 +23,7 @@ type SessionBindingContractEntry = { unbindAndVerify: (binding: SessionBindingRecord) => Promise; cleanup: () => Promise | void; }; -let discordRuntimeApiPromise: - | Promise - | undefined; -let feishuApiPromise: Promise | undefined; -let matrixApiPromise: Promise | undefined; -let matrixRuntimeApiPromise: - | Promise - | undefined; +const contractApiPromises = new Map>>(); const matrixSessionBindingStateDir = fs.mkdtempSync( path.join(os.tmpdir(), "openclaw-matrix-session-binding-contract-"), @@ -42,24 +35,18 @@ const matrixSessionBindingAuth = { accessToken: "token", } as const; -async function getDiscordRuntimeApi() { - discordRuntimeApiPromise ??= import("../../../../extensions/discord/runtime-api.js"); - return await discordRuntimeApiPromise; +function buildBundledPluginModuleId(pluginId: string, artifactBasename: string): string { + return ["..", "..", "..", "..", "extensions", pluginId, artifactBasename].join("/"); } -async function getFeishuApi() { - feishuApiPromise ??= import("../../../../extensions/feishu/api.js"); - return await feishuApiPromise; -} - -async function getMatrixApi() { - matrixApiPromise ??= import("../../../../extensions/matrix/api.js"); - return await matrixApiPromise; -} - -async function getMatrixRuntimeApi() { - matrixRuntimeApiPromise ??= import("../../../../extensions/matrix/runtime-api.js"); - return await matrixRuntimeApiPromise; +async function getContractApi>(pluginId: string): Promise { + const existing = contractApiPromises.get(pluginId); + if (existing) { + return (await existing) as T; + } + const next = import(buildBundledPluginModuleId(pluginId, "contract-api.js")); + contractApiPromises.set(pluginId, next); + return (await next) as T; } function expectResolvedSessionBinding(params: { @@ -112,8 +99,17 @@ function resetMatrixSessionBindingStateDir() { async function createContractMatrixThreadBindingManager() { resetMatrixSessionBindingStateDir(); - const { setMatrixRuntime } = await getMatrixRuntimeApi(); - const { createMatrixThreadBindingManager } = await getMatrixApi(); + const { setMatrixRuntime, createMatrixThreadBindingManager } = await getContractApi<{ + setMatrixRuntime: (runtime: unknown) => void; + createMatrixThreadBindingManager: (params: { + accountId: string; + auth: typeof matrixSessionBindingAuth; + client: unknown; + idleTimeoutMs: number; + maxAgeMs: number; + enableSweeper: boolean; + }) => Promise; + }>("matrix"); setMatrixRuntime({ state: { resolveStateDir: () => matrixSessionBindingStateDir, @@ -207,7 +203,13 @@ const sessionBindingContractEntries: Record< placements: ["current", "child"], }, getCapabilities: async () => { - const { createThreadBindingManager } = await getDiscordRuntimeApi(); + const { createThreadBindingManager } = await getContractApi<{ + createThreadBindingManager: (params: { + accountId: string; + persist: boolean; + enableSweeper: boolean; + }) => unknown; + }>("discord"); createThreadBindingManager({ accountId: "default", persist: false, @@ -219,7 +221,13 @@ const sessionBindingContractEntries: Record< }); }, bindAndResolve: async () => { - const { createThreadBindingManager } = await getDiscordRuntimeApi(); + const { createThreadBindingManager } = await getContractApi<{ + createThreadBindingManager: (params: { + accountId: string; + persist: boolean; + enableSweeper: boolean; + }) => unknown; + }>("discord"); createThreadBindingManager({ accountId: "default", persist: false, @@ -249,7 +257,13 @@ const sessionBindingContractEntries: Record< }, unbindAndVerify: unbindAndExpectClearedSessionBinding, cleanup: async () => { - const { createThreadBindingManager } = await getDiscordRuntimeApi(); + const { createThreadBindingManager } = await getContractApi<{ + createThreadBindingManager: (params: { + accountId: string; + persist: boolean; + enableSweeper: boolean; + }) => { stop: () => void }; + }>("discord"); const manager = createThreadBindingManager({ accountId: "default", persist: false, @@ -271,7 +285,12 @@ const sessionBindingContractEntries: Record< placements: ["current"], }, getCapabilities: async () => { - const { createFeishuThreadBindingManager } = await getFeishuApi(); + const { createFeishuThreadBindingManager } = await getContractApi<{ + createFeishuThreadBindingManager: (params: { + cfg: OpenClawConfig; + accountId: string; + }) => unknown; + }>("feishu"); createFeishuThreadBindingManager({ cfg: baseSessionBindingCfg, accountId: "default" }); return getSessionBindingService().getCapabilities({ channel: "feishu", @@ -279,7 +298,12 @@ const sessionBindingContractEntries: Record< }); }, bindAndResolve: async () => { - const { createFeishuThreadBindingManager } = await getFeishuApi(); + const { createFeishuThreadBindingManager } = await getContractApi<{ + createFeishuThreadBindingManager: (params: { + cfg: OpenClawConfig; + accountId: string; + }) => unknown; + }>("feishu"); createFeishuThreadBindingManager({ cfg: baseSessionBindingCfg, accountId: "default" }); const service = getSessionBindingService(); const binding = await service.bind({ @@ -307,7 +331,11 @@ const sessionBindingContractEntries: Record< }, unbindAndVerify: unbindAndExpectClearedSessionBinding, cleanup: async () => { - const { createFeishuThreadBindingManager } = await getFeishuApi(); + const { createFeishuThreadBindingManager } = await getContractApi<{ + createFeishuThreadBindingManager: (params: { cfg: OpenClawConfig; accountId: string }) => { + stop: () => void; + }; + }>("feishu"); const manager = createFeishuThreadBindingManager({ cfg: baseSessionBindingCfg, accountId: "default", @@ -423,7 +451,9 @@ const sessionBindingContractEntries: Record< }, unbindAndVerify: unbindAndExpectClearedSessionBinding, cleanup: async () => { - const { resetMatrixThreadBindingsForTests } = await getMatrixApi(); + const { resetMatrixThreadBindingsForTests } = await getContractApi<{ + resetMatrixThreadBindingsForTests: () => void; + }>("matrix"); resetMatrixThreadBindingsForTests(); resetMatrixSessionBindingStateDir(); expectClearedSessionBinding({ diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 2d8f0645a9d..12658a2d721 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -1,10 +1,5 @@ import { vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; -import { - listLineAccountIds, - resolveDefaultLineAccountId, - resolveLineAccount, -} from "../../../plugin-sdk/line.js"; import { listBundledChannelPlugins, setBundledChannelRuntime } from "../bundled.js"; import type { ChannelPlugin } from "../types.js"; import { channelPluginSurfaceKeys, type ChannelPluginSurface } from "./manifest.js"; @@ -50,13 +45,15 @@ const sendMessageMatrixMock = vi.hoisted(() => })), ); +const lineContractApi = await import(buildBundledPluginModuleId("line", "contract-api.js")); + setBundledChannelRuntime("line", { channel: { line: { - listLineAccountIds, - resolveDefaultLineAccountId, + listLineAccountIds: lineContractApi.listLineAccountIds, + resolveDefaultLineAccountId: lineContractApi.resolveDefaultLineAccountId, resolveLineAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => - resolveLineAccount({ cfg, accountId }), + lineContractApi.resolveLineAccount({ cfg, accountId }), }, }, } as never); diff --git a/src/channels/plugins/legacy-config.ts b/src/channels/plugins/legacy-config.ts new file mode 100644 index 00000000000..70c4ea76720 --- /dev/null +++ b/src/channels/plugins/legacy-config.ts @@ -0,0 +1,37 @@ +import type { LegacyConfigRule } from "../../config/legacy.shared.js"; +import type { OpenClawConfig } from "../../config/types.js"; +import { getBundledChannelContractSurfaces } from "./contract-surfaces.js"; + +type ChannelLegacyConfigSurface = { + legacyConfigRules?: LegacyConfigRule[]; + normalizeCompatibilityConfig?: (params: { cfg: OpenClawConfig }) => { + config: OpenClawConfig; + changes: string[]; + warnings?: string[]; + }; +}; + +function getChannelLegacyConfigSurfaces(): ChannelLegacyConfigSurface[] { + return getBundledChannelContractSurfaces() as ChannelLegacyConfigSurface[]; +} + +export function collectChannelLegacyConfigRules(): LegacyConfigRule[] { + return getChannelLegacyConfigSurfaces().flatMap((surface) => surface.legacyConfigRules ?? []); +} + +export function applyChannelDoctorCompatibilityMigrations(cfg: Record): { + next: Record; + changes: string[]; +} { + let nextCfg = cfg as OpenClawConfig & Record; + const changes: string[] = []; + for (const surface of getChannelLegacyConfigSurfaces()) { + const mutation = surface.normalizeCompatibilityConfig?.({ cfg: nextCfg }); + if (!mutation || mutation.changes.length === 0) { + continue; + } + nextCfg = mutation.config as OpenClawConfig & Record; + changes.push(...mutation.changes); + } + return { next: nextCfg, changes }; +} diff --git a/src/channels/plugins/normalize/imessage.ts b/src/channels/plugins/normalize/imessage.ts deleted file mode 100644 index 2b6c2d248f7..00000000000 --- a/src/channels/plugins/normalize/imessage.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { normalizeE164 } from "../../../plugin-sdk/account-resolution.js"; -import { looksLikeHandleOrPhoneTarget, trimMessagingTarget } from "./shared.js"; - -// Service prefixes that indicate explicit delivery method; must be preserved during normalization -const SERVICE_PREFIXES = ["imessage:", "sms:", "auto:"] as const; -const CHAT_TARGET_PREFIX_RE = - /^(chat_id:|chatid:|chat:|chat_guid:|chatguid:|guid:|chat_identifier:|chatidentifier:|chatident:)/i; - -export function normalizeIMessageHandle(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return ""; - } - const lowered = trimmed.toLowerCase(); - if (lowered.startsWith("imessage:")) { - return normalizeIMessageHandle(trimmed.slice("imessage:".length)); - } - if (lowered.startsWith("sms:")) { - return normalizeIMessageHandle(trimmed.slice("sms:".length)); - } - if (lowered.startsWith("auto:")) { - return normalizeIMessageHandle(trimmed.slice("auto:".length)); - } - if (CHAT_TARGET_PREFIX_RE.test(trimmed)) { - const prefix = trimmed.match(CHAT_TARGET_PREFIX_RE)?.[0]; - if (!prefix) { - return ""; - } - const value = trimmed.slice(prefix.length).trim(); - return `${prefix.toLowerCase()}${value}`; - } - if (trimmed.includes("@")) { - return trimmed.toLowerCase(); - } - const normalized = normalizeE164(trimmed); - if (normalized) { - return normalized; - } - return trimmed.replace(/\s+/g, ""); -} - -export function normalizeIMessageMessagingTarget(raw: string): string | undefined { - const trimmed = trimMessagingTarget(raw); - if (!trimmed) { - return undefined; - } - - // Preserve service prefix if present (e.g., "sms:+1555" → "sms:+15551234567") - const lower = trimmed.toLowerCase(); - for (const prefix of SERVICE_PREFIXES) { - if (lower.startsWith(prefix)) { - const remainder = trimmed.slice(prefix.length).trim(); - const normalizedHandle = normalizeIMessageHandle(remainder); - if (!normalizedHandle) { - return undefined; - } - if (CHAT_TARGET_PREFIX_RE.test(normalizedHandle)) { - return normalizedHandle; - } - return `${prefix}${normalizedHandle}`; - } - } - - const normalized = normalizeIMessageHandle(trimmed); - return normalized || undefined; -} - -export function looksLikeIMessageTargetId(raw: string): boolean { - const trimmed = trimMessagingTarget(raw); - if (!trimmed) { - return false; - } - if (CHAT_TARGET_PREFIX_RE.test(trimmed)) { - return true; - } - return looksLikeHandleOrPhoneTarget({ - raw: trimmed, - prefixPattern: /^(imessage:|sms:|auto:)/i, - }); -} diff --git a/src/channels/plugins/normalize/signal.ts b/src/channels/plugins/normalize/signal.ts deleted file mode 100644 index c4b9e4090cf..00000000000 --- a/src/channels/plugins/normalize/signal.ts +++ /dev/null @@ -1,70 +0,0 @@ -export function normalizeSignalMessagingTarget(raw: string): string | undefined { - const trimmed = raw.trim(); - if (!trimmed) { - return undefined; - } - let normalized = trimmed; - if (normalized.toLowerCase().startsWith("signal:")) { - normalized = normalized.slice("signal:".length).trim(); - } - if (!normalized) { - return undefined; - } - const lower = normalized.toLowerCase(); - if (lower.startsWith("group:")) { - const id = normalized.slice("group:".length).trim(); - // Signal group IDs are base64-like and case-sensitive. Preserve ID casing. - return id ? `group:${id}` : undefined; - } - if (lower.startsWith("username:")) { - const id = normalized.slice("username:".length).trim(); - return id ? `username:${id}`.toLowerCase() : undefined; - } - if (lower.startsWith("u:")) { - const id = normalized.slice("u:".length).trim(); - return id ? `username:${id}`.toLowerCase() : undefined; - } - if (lower.startsWith("uuid:")) { - const id = normalized.slice("uuid:".length).trim(); - return id ? id.toLowerCase() : undefined; - } - return normalized.toLowerCase(); -} - -// UUID pattern for signal-cli recipient IDs -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const UUID_COMPACT_PATTERN = /^[0-9a-f]{32}$/i; - -export function looksLikeSignalTargetId(raw: string, normalized?: string): boolean { - const candidates = [raw, normalized ?? ""].map((value) => value.trim()).filter(Boolean); - - for (const candidate of candidates) { - if (/^(signal:)?(group:|username:|u:)/i.test(candidate)) { - return true; - } - if (/^(signal:)?uuid:/i.test(candidate)) { - const stripped = candidate - .replace(/^signal:/i, "") - .replace(/^uuid:/i, "") - .trim(); - if (!stripped) { - continue; - } - if (UUID_PATTERN.test(stripped) || UUID_COMPACT_PATTERN.test(stripped)) { - return true; - } - continue; - } - - const withoutSignalPrefix = candidate.replace(/^signal:/i, "").trim(); - // Accept UUIDs (used by signal-cli for reactions) - if (UUID_PATTERN.test(withoutSignalPrefix) || UUID_COMPACT_PATTERN.test(withoutSignalPrefix)) { - return true; - } - if (/^\+?\d{3,}$/.test(withoutSignalPrefix)) { - return true; - } - } - - return false; -} diff --git a/src/channels/plugins/normalize/slack.ts b/src/channels/plugins/normalize/slack.ts deleted file mode 100644 index e3259c2bb69..00000000000 --- a/src/channels/plugins/normalize/slack.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - buildMessagingTarget, - ensureTargetId, - parseMentionPrefixOrAtUserTarget, -} from "../../targets.js"; - -export function normalizeSlackMessagingTarget(raw: string): string | undefined { - const trimmed = raw.trim(); - if (!trimmed) { - return undefined; - } - const userTarget = parseMentionPrefixOrAtUserTarget({ - raw: trimmed, - mentionPattern: /^<@([A-Z0-9]+)>$/i, - prefixes: [ - { prefix: "user:", kind: "user" }, - { prefix: "channel:", kind: "channel" }, - { prefix: "slack:", kind: "user" }, - ], - atUserPattern: /^[A-Z0-9]+$/i, - atUserErrorMessage: "Slack DMs require a user id (use user: or <@id>)", - }); - if (userTarget) { - return userTarget.normalized; - } - if (trimmed.startsWith("#")) { - const candidate = trimmed.slice(1).trim(); - const id = ensureTargetId({ - candidate, - pattern: /^[A-Z0-9]+$/i, - errorMessage: "Slack channels require a channel id (use channel:)", - }); - return buildMessagingTarget("channel", id, trimmed).normalized; - } - return buildMessagingTarget("channel", trimmed, trimmed).normalized; -} - -export function looksLikeSlackTargetId(raw: string): boolean { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - if (/^<@([A-Z0-9]+)>$/i.test(trimmed)) { - return true; - } - if (/^(user|channel):/i.test(trimmed)) { - return true; - } - if (/^slack:/i.test(trimmed)) { - return true; - } - if (/^[@#]/.test(trimmed)) { - return true; - } - return /^[CUWGD][A-Z0-9]{8,}$/i.test(trimmed); -} diff --git a/src/channels/plugins/normalize/whatsapp.ts b/src/channels/plugins/normalize/whatsapp.ts deleted file mode 100644 index dde743bb231..00000000000 --- a/src/channels/plugins/normalize/whatsapp.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { normalizeE164 } from "../../../utils.js"; -import { looksLikeHandleOrPhoneTarget, trimMessagingTarget } from "./shared.js"; - -const WHATSAPP_USER_JID_RE = /^(\d+)(?::\d+)?@s\.whatsapp\.net$/i; -const WHATSAPP_LID_RE = /^(\d+)@lid$/i; - -function stripWhatsAppTargetPrefixes(value: string): string { - let candidate = value.trim(); - for (;;) { - const before = candidate; - candidate = candidate.replace(/^whatsapp:/i, "").trim(); - if (candidate === before) { - return candidate; - } - } -} - -export function isWhatsAppGroupJid(value: string): boolean { - const candidate = stripWhatsAppTargetPrefixes(value); - const lower = candidate.toLowerCase(); - if (!lower.endsWith("@g.us")) { - return false; - } - const localPart = candidate.slice(0, candidate.length - "@g.us".length); - if (!localPart || localPart.includes("@")) { - return false; - } - return /^[0-9]+(-[0-9]+)*$/.test(localPart); -} - -export function isWhatsAppUserTarget(value: string): boolean { - const candidate = stripWhatsAppTargetPrefixes(value); - return WHATSAPP_USER_JID_RE.test(candidate) || WHATSAPP_LID_RE.test(candidate); -} - -function extractUserJidPhone(jid: string): string | null { - const userMatch = jid.match(WHATSAPP_USER_JID_RE); - if (userMatch) { - return userMatch[1]; - } - const lidMatch = jid.match(WHATSAPP_LID_RE); - if (lidMatch) { - return lidMatch[1]; - } - return null; -} - -export function normalizeWhatsAppTarget(value: string): string | null { - const candidate = stripWhatsAppTargetPrefixes(value); - if (!candidate) { - return null; - } - if (isWhatsAppGroupJid(candidate)) { - const localPart = candidate.slice(0, candidate.length - "@g.us".length); - return `${localPart}@g.us`; - } - if (isWhatsAppUserTarget(candidate)) { - const phone = extractUserJidPhone(candidate); - if (!phone) { - return null; - } - const normalized = normalizeE164(phone); - return normalized.length > 1 ? normalized : null; - } - if (candidate.includes("@")) { - return null; - } - const normalized = normalizeE164(candidate); - return normalized.length > 1 ? normalized : null; -} - -export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined { - const trimmed = trimMessagingTarget(raw); - if (!trimmed) { - return undefined; - } - return normalizeWhatsAppTarget(trimmed) ?? undefined; -} - -export function normalizeWhatsAppAllowFromEntries(allowFrom: Array): string[] { - return allowFrom - .map((entry) => String(entry).trim()) - .filter((entry): entry is string => Boolean(entry)) - .map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry))) - .filter((entry): entry is string => Boolean(entry)); -} - -export function looksLikeWhatsAppTargetId(raw: string): boolean { - return looksLikeHandleOrPhoneTarget({ - raw, - prefixPattern: /^whatsapp:/i, - }); -} diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index ced09844289..9c5af5fd59e 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -51,7 +51,7 @@ export function resolveScopedChannelMediaMaxBytes(params: { }); } -export function createScopedChannelMediaMaxBytesResolver(channel: "imessage" | "signal") { +export function createScopedChannelMediaMaxBytesResolver(channel: string) { return (params: { cfg: OpenClawConfig; accountId?: string | null }) => resolveScopedChannelMediaMaxBytes({ cfg: params.cfg, @@ -66,7 +66,7 @@ export function createDirectTextMediaOutbound< TOpts extends Record, TResult extends DirectSendResult, >(params: { - channel: "imessage" | "signal"; + channel: string; resolveSender: (deps: OutboundSendDeps | undefined) => DirectSendFn; resolveMaxBytes: (params: { cfg: OpenClawConfig; diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index 38a75d79bd7..48b03ee4261 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -1,6 +1,7 @@ import { z, type ZodType } from "zod"; import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import { getBundledChannelContractSurfaceEntries } from "./contract-surfaces.js"; import type { ChannelSetupAdapter } from "./types.adapters.js"; import type { ChannelSetupInput } from "./types.core.js"; @@ -405,74 +406,23 @@ const COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([ "defaultTo", ]); -const SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL: Record> = { - matrix: new Set([ - "deviceId", - "avatarUrl", - "initialSyncLimit", - "encryption", - "allowlistOnly", - "allowBots", - "replyToMode", - "threadReplies", - "textChunkLimit", - "chunkMode", - "responsePrefix", - "ackReaction", - "ackReactionScope", - "reactionNotifications", - "threadBindings", - "startupVerification", - "startupVerificationCooldownHours", - "mediaMaxMb", - "autoJoin", - "autoJoinAllowlist", - "dm", - "groups", - "rooms", - "actions", - ]), - telegram: new Set(["streaming"]), +type ChannelSetupPromotionSurface = { + singleAccountKeysToMove?: readonly string[]; + namedAccountPromotionKeys?: readonly string[]; + resolveSingleAccountPromotionTarget?: (params: { + channel: ChannelSectionBase; + }) => string | undefined; }; -const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = new Set([ - "name", - "homeserver", - "userId", - "accessToken", - "password", - "deviceId", - "deviceName", - "avatarUrl", - "initialSyncLimit", - "encryption", -]); - -export const MATRIX_SHARED_MULTI_ACCOUNT_DEFAULT_KEYS = new Set([ - "dmPolicy", - "allowFrom", - "groupPolicy", - "groupAllowFrom", - "allowlistOnly", - "replyToMode", - "threadReplies", - "textChunkLimit", - "chunkMode", - "responsePrefix", - "ackReaction", - "ackReactionScope", - "reactionNotifications", - "threadBindings", - "startupVerification", - "startupVerificationCooldownHours", - "mediaMaxMb", - "autoJoin", - "autoJoinAllowlist", - "dm", - "groups", - "rooms", - "actions", -]); +function getChannelSetupPromotionSurface(channelKey: string): ChannelSetupPromotionSurface | null { + const entry = getBundledChannelContractSurfaceEntries().find( + (candidate) => candidate.pluginId === channelKey, + ); + if (!entry || !entry.surface || typeof entry.surface !== "object") { + return null; + } + return entry.surface as ChannelSetupPromotionSurface; +} export function shouldMoveSingleAccountChannelKey(params: { channelKey: string; @@ -481,7 +431,11 @@ export function shouldMoveSingleAccountChannelKey(params: { if (COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE.has(params.key)) { return true; } - return SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL[params.channelKey]?.has(params.key) ?? false; + const contractKeys = getChannelSetupPromotionSurface(params.channelKey)?.singleAccountKeysToMove; + if (contractKeys?.includes(params.key)) { + return true; + } + return false; } export function resolveSingleAccountKeysToMove(params: { @@ -491,6 +445,9 @@ export function resolveSingleAccountKeysToMove(params: { const hasNamedAccounts = Object.keys((params.channel.accounts as Record) ?? {}).filter(Boolean).length > 0; + const namedAccountPromotionKeys = getChannelSetupPromotionSurface( + params.channelKey, + )?.namedAccountPromotionKeys; return Object.entries(params.channel) .filter(([key, value]) => { if (key === "accounts" || key === "enabled" || value === undefined) { @@ -500,9 +457,10 @@ export function resolveSingleAccountKeysToMove(params: { return false; } if ( - params.channelKey === "matrix" && hasNamedAccounts && - !MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS.has(key) + namedAccountPromotionKeys && + namedAccountPromotionKeys.length > 0 && + !namedAccountPromotionKeys.includes(key) ) { return false; } @@ -515,41 +473,12 @@ export function resolveSingleAccountPromotionTarget(params: { channelKey: string; channel: ChannelSectionBase; }): string { - if (params.channelKey !== "matrix") { - return DEFAULT_ACCOUNT_ID; - } - const accounts = params.channel.accounts ?? {}; - const normalizedDefaultAccount = - typeof params.channel.defaultAccount === "string" && params.channel.defaultAccount.trim() - ? normalizeAccountId(params.channel.defaultAccount) - : undefined; - if (normalizedDefaultAccount) { - if (normalizedDefaultAccount !== DEFAULT_ACCOUNT_ID) { - const matchedAccountId = Object.entries(accounts).find( - ([accountId, value]) => - accountId && - value && - typeof value === "object" && - normalizeAccountId(accountId) === normalizedDefaultAccount, - )?.[0]; - if (matchedAccountId) { - return matchedAccountId; - } - } - return DEFAULT_ACCOUNT_ID; - } - const namedAccounts = Object.entries(accounts).filter( - ([accountId, value]) => accountId && typeof value === "object" && value, - ); - if (namedAccounts.length === 1) { - return namedAccounts[0][0]; - } - if ( - namedAccounts.length > 1 && - accounts[DEFAULT_ACCOUNT_ID] && - typeof accounts[DEFAULT_ACCOUNT_ID] === "object" - ) { - return DEFAULT_ACCOUNT_ID; + const surface = getChannelSetupPromotionSurface(params.channelKey); + const resolved = surface?.resolveSingleAccountPromotionTarget?.({ + channel: params.channel, + }); + if (typeof resolved === "string" && resolved.trim()) { + return normalizeAccountId(resolved); } return DEFAULT_ACCOUNT_ID; } @@ -610,9 +539,6 @@ export function moveSingleAccountChannelSectionToDefaultAccount(params: { const accounts = base.accounts ?? {}; if (Object.keys(accounts).length > 0) { - if (params.channelKey !== "matrix") { - return params.cfg; - } const keysToMove = resolveSingleAccountKeysToMove({ channelKey: params.channelKey, channel: base, diff --git a/src/channels/plugins/setup-wizard-helpers.ts b/src/channels/plugins/setup-wizard-helpers.ts index 707ed302da4..7024cddad73 100644 --- a/src/channels/plugins/setup-wizard-helpers.ts +++ b/src/channels/plugins/setup-wizard-helpers.ts @@ -239,7 +239,7 @@ export async function resolveAccountIdForConfigure(params: { export function setAccountAllowFromForChannel(params: { cfg: OpenClawConfig; - channel: "imessage" | "signal"; + channel: string; accountId: string; allowFrom: string[]; }): OpenClawConfig { @@ -535,7 +535,7 @@ export function createTopLevelChannelGroupPolicySetter(params: { export function setChannelDmPolicyWithAllowFrom(params: { cfg: OpenClawConfig; - channel: "imessage" | "signal" | "telegram"; + channel: string; dmPolicy: DmPolicy; }): OpenClawConfig { const { cfg, channel, dmPolicy } = params; @@ -554,9 +554,9 @@ export function setChannelDmPolicyWithAllowFrom(params: { }; } -export function setLegacyChannelDmPolicyWithAllowFrom(params: { +export function setCompatChannelDmPolicyWithAllowFrom(params: { cfg: OpenClawConfig; - channel: LegacyDmChannel; + channel: string; dmPolicy: DmPolicy; }): OpenClawConfig { const channelConfig = (params.cfg.channels?.[params.channel] as @@ -571,7 +571,7 @@ export function setLegacyChannelDmPolicyWithAllowFrom(params: { const existingAllowFrom = channelConfig.allowFrom ?? channelConfig.dm?.allowFrom; const allowFrom = params.dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; - return patchLegacyDmChannelConfig({ + return patchCompatDmChannelConfig({ cfg: params.cfg, channel: params.channel, patch: { @@ -581,12 +581,12 @@ export function setLegacyChannelDmPolicyWithAllowFrom(params: { }); } -export function setLegacyChannelAllowFrom(params: { +export function setCompatChannelAllowFrom(params: { cfg: OpenClawConfig; - channel: LegacyDmChannel; + channel: string; allowFrom: string[]; }): OpenClawConfig { - return patchLegacyDmChannelConfig({ + return patchCompatDmChannelConfig({ cfg: params.cfg, channel: params.channel, patch: { allowFrom: params.allowFrom }, @@ -595,7 +595,7 @@ export function setLegacyChannelAllowFrom(params: { export function setAccountGroupPolicyForChannel(params: { cfg: OpenClawConfig; - channel: "discord" | "slack"; + channel: string; accountId: string; groupPolicy: GroupPolicy; }): OpenClawConfig { @@ -609,7 +609,7 @@ export function setAccountGroupPolicyForChannel(params: { export function setAccountDmAllowFromForChannel(params: { cfg: OpenClawConfig; - channel: "discord" | "slack"; + channel: string; accountId: string; allowFrom: string[]; }): OpenClawConfig { @@ -621,9 +621,9 @@ export function setAccountDmAllowFromForChannel(params: { }); } -export function createLegacyCompatChannelDmPolicy(params: { +export function createCompatChannelDmPolicy(params: { label: string; - channel: LegacyDmChannel; + channel: string; promptAllowFrom?: ChannelSetupDmPolicy["promptAllowFrom"]; }): ChannelSetupDmPolicy { return { @@ -716,7 +716,7 @@ export function createLegacyCompatChannelDmPolicy(params: { : {}), }, }) - : setLegacyChannelDmPolicyWithAllowFrom({ + : setCompatChannelDmPolicyWithAllowFrom({ cfg, channel: params.channel, dmPolicy: policy, @@ -751,7 +751,7 @@ export async function resolveGroupAllowlistWithLookupNotes(params: { } export function createAccountScopedAllowFromSection(params: { - channel: "discord" | "slack"; + channel: string; credentialInputKey?: NonNullable["credentialInputKey"]; helpTitle?: string; helpLines?: string[]; @@ -781,7 +781,7 @@ export function createAccountScopedAllowFromSection(params: { } export function createAccountScopedGroupAccessSection(params: { - channel: "discord" | "slack"; + channel: string; label: string; placeholder: string; helpTitle?: string; @@ -844,20 +844,12 @@ export function createAccountScopedGroupAccessSection(params: { }; } -type AccountScopedChannel = - | "bluebubbles" - | "discord" - | "feishu" - | "imessage" - | "line" - | "signal" - | "slack" - | "telegram"; -type LegacyDmChannel = "discord" | "slack"; +type AccountScopedChannel = string; +type CompatDmChannel = string; -export function patchLegacyDmChannelConfig(params: { +export function patchCompatDmChannelConfig(params: { cfg: OpenClawConfig; - channel: LegacyDmChannel; + channel: string; patch: Record; }): OpenClawConfig { const { cfg, channel, patch } = params; @@ -936,9 +928,9 @@ export function patchChannelConfigForAccount(params: { export function applySingleTokenPromptResult(params: { cfg: OpenClawConfig; - channel: "discord" | "telegram"; + channel: string; accountId: string; - tokenPatchKey: "token" | "botToken"; + tokenPatchKey: string; tokenResult: { useEnv: boolean; token: SecretInput | null; @@ -1273,7 +1265,7 @@ export function createPromptParsedAllowFromForAccount; @@ -1555,7 +1547,7 @@ export async function promptResolvedAllowFrom(params: { export async function promptLegacyChannelAllowFrom(params: { cfg: OpenClawConfig; - channel: LegacyDmChannel; + channel: CompatDmChannel; prompter: WizardPrompter; existing: Array; token?: string | null; @@ -1580,7 +1572,7 @@ export async function promptLegacyChannelAllowFrom(params: { invalidWithoutTokenNote: params.invalidWithoutTokenNote, resolveEntries: params.resolveEntries, }); - return setLegacyChannelAllowFrom({ + return setCompatChannelAllowFrom({ cfg: params.cfg, channel: params.channel, allowFrom: unique, @@ -1589,7 +1581,7 @@ export async function promptLegacyChannelAllowFrom(params: { export async function promptLegacyChannelAllowFromForAccount(params: { cfg: OpenClawConfig; - channel: LegacyDmChannel; + channel: CompatDmChannel; prompter: WizardPrompter; accountId?: string; defaultAccountId: string; @@ -1624,3 +1616,9 @@ export async function promptLegacyChannelAllowFromForAccount(params: { resolveEntries: params.resolveEntries, }); } + +// Backwards-compatible aliases for existing setup SDK consumers. +export const patchLegacyDmChannelConfig = patchCompatDmChannelConfig; +export const setLegacyChannelDmPolicyWithAllowFrom = setCompatChannelDmPolicyWithAllowFrom; +export const setLegacyChannelAllowFrom = setCompatChannelAllowFrom; +export const createLegacyCompatChannelDmPolicy = createCompatChannelDmPolicy; diff --git a/src/channels/plugins/setup-wizard-types.ts b/src/channels/plugins/setup-wizard-types.ts index f5939757626..ade27847dc4 100644 --- a/src/channels/plugins/setup-wizard-types.ts +++ b/src/channels/plugins/setup-wizard-types.ts @@ -18,9 +18,6 @@ export type SetupChannelsOptions = { onAccountId?: (channel: ChannelId, accountId: string) => void; onResolvedPlugin?: (channel: ChannelId, plugin: ChannelSetupPlugin) => void; promptAccountIds?: boolean; - whatsappAccountId?: string; - promptWhatsAppAccountId?: boolean; - onWhatsAppAccountId?: (accountId: string) => void; forceAllowFromChannels?: ChannelId[]; skipStatusNote?: boolean; skipDmPolicyPrompt?: boolean; diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 430a0f883d8..98f0087a5be 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -1,6 +1,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ConfiguredBindingRule } from "../../config/bindings.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { LegacyConfigRule } from "../../config/legacy.shared.js"; import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; import type { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js"; import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js"; @@ -19,6 +20,7 @@ import type { ChannelDirectoryEntry, ChannelGroupContext, ChannelHeartbeatDeps, + ChannelLegacyStateMigrationPlan, ChannelLogSink, ChannelOutboundTargetMode, ChannelPollContext, @@ -393,6 +395,7 @@ export type ChannelPairingAdapter = { export type ChannelGatewayAdapter = { startAccount?: (ctx: ChannelGatewayContext) => Promise; stopAccount?: (ctx: ChannelGatewayContext) => Promise; + resolveGatewayAuthBypassPaths?: (params: { cfg: OpenClawConfig }) => string[]; loginWithQrStart?: (params: { accountId?: string; force?: boolean; @@ -543,6 +546,8 @@ export type ChannelDoctorConfigMutation = { warnings?: string[]; }; +export type ChannelDoctorLegacyConfigRule = LegacyConfigRule; + export type ChannelDoctorSequenceResult = { changeNotes: string[]; warningNotes: string[]; @@ -562,6 +567,7 @@ export type ChannelDoctorAdapter = { groupModel?: "sender" | "route" | "hybrid"; groupAllowFromFallbackToAllowFrom?: boolean; warnOnEmptyGroupSenderAllowlist?: boolean; + legacyConfigRules?: LegacyConfigRule[]; normalizeCompatibilityConfig?: (params: { cfg: OpenClawConfig }) => ChannelDoctorConfigMutation; collectPreviewWarnings?: (params: { cfg: OpenClawConfig; @@ -612,6 +618,12 @@ export type ChannelLifecycleAdapter = { trigger?: string; logPrefix?: string; }) => Promise | void; + detectLegacyStateMigrations?: (params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + stateDir: string; + oauthDir: string; + }) => ChannelLegacyStateMigrationPlan[] | Promise; }; export type ChannelApprovalDeliveryAdapter = { @@ -840,6 +852,9 @@ export type ChannelConversationBindingSupport = { parentConversationId?: string; }; }) => ReplyPayload["channelData"] | null | Promise; + buildModelOverrideParentCandidates?: (params: { + parentConversationId?: string | null; + }) => string[] | null | undefined; shouldStripThreadFromAnnounceOrigin?: (params: { requester: { channel?: string; @@ -882,6 +897,10 @@ export type ChannelConversationBindingSupport = { }; export type ChannelSecurityAdapter = { + applyConfigFixes?: (params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + }) => ChannelDoctorConfigMutation | Promise; resolveDmPolicy?: ( ctx: ChannelSecurityContext, ) => ChannelSecurityDmPolicy | null; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index f01229882a6..a83e6fa72bd 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -120,6 +120,13 @@ export type ChannelHeartbeatDeps = { hasActiveWebListener?: (accountId?: string) => boolean; }; +export type ChannelLegacyStateMigrationPlan = { + kind: "copy" | "move"; + label: string; + sourcePath: string; + targetPath: string; +}; + /** User-facing metadata used in docs, pickers, and setup surfaces. */ export type ChannelMeta = { id: ChannelId; @@ -458,6 +465,11 @@ export type ChannelMessagingAdapter = { */ inferTargetChatType?: (params: { to: string }) => ChatType | undefined; buildCrossContextComponents?: ChannelCrossContextComponentsFactory; + transformReplyPayload?: (params: { + payload: ReplyPayload; + cfg: OpenClawConfig; + accountId?: string | null; + }) => ReplyPayload | null; enableInteractiveReplies?: (params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/src/channels/plugins/whatsapp-heartbeat.ts b/src/channels/plugins/whatsapp-heartbeat.ts deleted file mode 100644 index 3fd378aba37..00000000000 --- a/src/channels/plugins/whatsapp-heartbeat.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { OpenClawConfig } from "../../config/config.js"; -import { resolveStorePath } from "../../config/sessions/paths.js"; -import { loadSessionStoreSummary } from "../../config/sessions/store-summary.js"; -import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js"; -import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; -import { normalizeE164 } from "../../utils.js"; -import { normalizeChatChannelId } from "../registry.js"; - -type HeartbeatRecipientsResult = { recipients: string[]; source: string }; -type HeartbeatRecipientsOpts = { to?: string; all?: boolean; accountId?: string }; - -function resolveConfiguredAllowFrom(cfg: OpenClawConfig, accountId: string): string[] { - const channelCfg = cfg.channels?.whatsapp; - if (!channelCfg) { - return []; - } - const allowFrom = - accountId === DEFAULT_ACCOUNT_ID - ? channelCfg.allowFrom - : channelCfg.accounts?.[accountId]?.allowFrom ?? channelCfg.allowFrom; - return Array.isArray(allowFrom) ? allowFrom.filter((value) => value !== "*").map(normalizeE164) : []; -} - -function getSessionRecipients(cfg: OpenClawConfig) { - const sessionCfg = cfg.session; - const scope = sessionCfg?.scope ?? "per-sender"; - if (scope === "global") { - return []; - } - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStoreSummary(storePath); - const isGroupKey = (key: string) => - key.includes(":group:") || key.includes(":channel:") || key.includes("@g.us"); - const isCronKey = (key: string) => key.startsWith("cron:"); - - const recipients = Object.entries(store) - .filter(([key]) => key !== "global" && key !== "unknown") - .filter(([key]) => !isGroupKey(key) && !isCronKey(key)) - .map(([_, entry]) => ({ - to: - normalizeChatChannelId(entry?.lastChannel) === "whatsapp" && entry?.lastTo - ? normalizeE164(entry.lastTo) - : "", - updatedAt: entry?.updatedAt ?? 0, - })) - .filter(({ to }) => to.length > 1) - .toSorted((a, b) => b.updatedAt - a.updatedAt); - - // Dedupe while preserving recency ordering. - const seen = new Set(); - return recipients.filter((r) => { - if (seen.has(r.to)) { - return false; - } - seen.add(r.to); - return true; - }); -} - -export function resolveWhatsAppHeartbeatRecipients( - cfg: OpenClawConfig, - opts: HeartbeatRecipientsOpts = {}, -): HeartbeatRecipientsResult { - if (opts.to) { - return { recipients: [normalizeE164(opts.to)], source: "flag" }; - } - - const sessionRecipients = getSessionRecipients(cfg); - const resolvedAccountId = opts.accountId?.trim() || DEFAULT_ACCOUNT_ID; - const configuredAllowFrom = resolveConfiguredAllowFrom(cfg, resolvedAccountId); - const storeAllowFrom = readChannelAllowFromStoreSync( - "whatsapp", - process.env, - resolvedAccountId, - ).map(normalizeE164); - - const unique = (list: string[]) => [...new Set(list.filter(Boolean))]; - const allowFrom = unique([...configuredAllowFrom, ...storeAllowFrom]); - - if (opts.all) { - const all = unique([...sessionRecipients.map((s) => s.to), ...allowFrom]); - return { recipients: all, source: "all" }; - } - - if (allowFrom.length > 0) { - const allowSet = new Set(allowFrom); - const authorizedSessionRecipients = sessionRecipients - .map((entry) => entry.to) - .filter((recipient) => allowSet.has(recipient)); - if (authorizedSessionRecipients.length === 1) { - return { recipients: [authorizedSessionRecipients[0]], source: "session-single" }; - } - if (authorizedSessionRecipients.length > 1) { - return { recipients: authorizedSessionRecipients, source: "session-ambiguous" }; - } - return { recipients: allowFrom, source: "allowFrom" }; - } - - if (sessionRecipients.length === 1) { - return { recipients: [sessionRecipients[0].to], source: "session-single" }; - } - if (sessionRecipients.length > 1) { - return { - recipients: sessionRecipients.map((s) => s.to), - source: "session-ambiguous", - }; - } - - return { recipients: allowFrom, source: "allowFrom" }; -} diff --git a/src/channels/read-only-account-inspect.telegram.ts b/src/channels/read-only-account-inspect.telegram.ts deleted file mode 100644 index 0bbccd805da..00000000000 --- a/src/channels/read-only-account-inspect.telegram.ts +++ /dev/null @@ -1,286 +0,0 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { coerceSecretRef } from "../config/types.secrets.js"; -import type { TelegramAccountConfig } from "../config/types.telegram.js"; -import { tryReadSecretFileSync } from "../infra/secret-file.js"; -import { - resolveAccountWithDefaultFallback, - listCombinedAccountIds, - resolveListedDefaultAccountId, - resolveAccountEntry, -} from "../plugin-sdk/account-core.js"; -import { resolveDefaultSecretProviderAlias } from "../plugin-sdk/provider-auth.js"; -import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js"; -import { - DEFAULT_ACCOUNT_ID, - normalizeAccountId, - normalizeOptionalAccountId, -} from "../routing/session-key.js"; - -export type TelegramCredentialStatus = "available" | "configured_unavailable" | "missing"; - -export type InspectedTelegramAccount = { - accountId: string; - enabled: boolean; - name?: string; - token: string; - tokenSource: "env" | "tokenFile" | "config" | "none"; - tokenStatus: TelegramCredentialStatus; - configured: boolean; - config: TelegramAccountConfig; -}; - -export function normalizeTelegramAllowFromEntry(raw: unknown): string { - const base = typeof raw === "string" ? raw : typeof raw === "number" ? String(raw) : ""; - return base - .trim() - .replace(/^(telegram|tg):/i, "") - .trim(); -} - -export function isNumericTelegramUserId(raw: string): boolean { - return /^-?\d+$/.test(raw); -} - -function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { - const ids = new Set(); - for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) { - if (key) { - ids.add(normalizeAccountId(key)); - } - } - return [...ids]; -} - -export function listTelegramAccountIds(cfg: OpenClawConfig): string[] { - return listCombinedAccountIds({ - configuredAccountIds: listConfiguredAccountIds(cfg), - additionalAccountIds: listBoundAccountIds(cfg, "telegram"), - fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID, - }); -} - -export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { - const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram"); - if (boundDefault) { - return boundDefault; - } - return resolveListedDefaultAccountId({ - accountIds: listTelegramAccountIds(cfg), - configuredDefaultAccountId: normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount), - }); -} - -function resolveTelegramAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): TelegramAccountConfig | undefined { - return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalizeAccountId(accountId)); -} - -function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): TelegramAccountConfig { - const { - accounts: _ignored, - defaultAccount: _ignoredDefaultAccount, - groups: channelGroups, - ...base - } = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & { - accounts?: unknown; - defaultAccount?: unknown; - }; - const account = resolveTelegramAccountConfig(cfg, accountId) ?? {}; - const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {}); - const groups = account.groups ?? (configuredAccountIds.length > 1 ? undefined : channelGroups); - return { ...base, ...account, groups }; -} - -function inspectTokenFile(pathValue: unknown): { - token: string; - tokenSource: "tokenFile" | "none"; - tokenStatus: TelegramCredentialStatus; -} | null { - const tokenFile = typeof pathValue === "string" ? pathValue.trim() : ""; - if (!tokenFile) { - return null; - } - const token = tryReadSecretFileSync(tokenFile, "Telegram bot token", { - rejectSymlink: true, - }); - return { - token: token ?? "", - tokenSource: "tokenFile", - tokenStatus: token ? "available" : "configured_unavailable", - }; -} - -function canResolveEnvSecretRefInReadOnlyPath(params: { - cfg: OpenClawConfig; - provider: string; - id: string; -}): boolean { - const providerConfig = params.cfg.secrets?.providers?.[params.provider]; - if (!providerConfig) { - return params.provider === resolveDefaultSecretProviderAlias(params.cfg, "env"); - } - if (providerConfig.source !== "env") { - return false; - } - const allowlist = providerConfig.allowlist; - return !allowlist || allowlist.includes(params.id); -} - -function hasConfiguredSecretInput(value: unknown): boolean { - return Boolean(coerceSecretRef(value) || (typeof value === "string" && value.trim())); -} - -function normalizeSecretInputString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -function inspectTokenValue(params: { cfg: OpenClawConfig; value: unknown }): { - token: string; - tokenSource: "config" | "env" | "none"; - tokenStatus: TelegramCredentialStatus; -} | null { - const ref = coerceSecretRef(params.value, params.cfg.secrets?.defaults); - if (ref?.source === "env") { - if ( - !canResolveEnvSecretRefInReadOnlyPath({ - cfg: params.cfg, - provider: ref.provider, - id: ref.id, - }) - ) { - return { token: "", tokenSource: "env", tokenStatus: "configured_unavailable" }; - } - const envValue = process.env[ref.id]; - if (envValue && envValue.trim()) { - return { token: envValue.trim(), tokenSource: "env", tokenStatus: "available" }; - } - return { token: "", tokenSource: "env", tokenStatus: "configured_unavailable" }; - } - const token = normalizeSecretInputString(params.value); - if (token) { - return { token, tokenSource: "config", tokenStatus: "available" }; - } - if (hasConfiguredSecretInput(params.value)) { - return { token: "", tokenSource: "config", tokenStatus: "configured_unavailable" }; - } - return null; -} - -function inspectTelegramAccountPrimary(params: { - cfg: OpenClawConfig; - accountId: string; - envToken?: string | null; -}): InspectedTelegramAccount { - const accountId = normalizeAccountId(params.accountId); - const merged = mergeTelegramAccountConfig(params.cfg, accountId); - const enabled = params.cfg.channels?.telegram?.enabled !== false && merged.enabled !== false; - - const accountConfig = resolveTelegramAccountConfig(params.cfg, accountId); - const accountTokenFile = inspectTokenFile(accountConfig?.tokenFile); - if (accountTokenFile) { - return { - accountId, - enabled, - name: merged.name?.trim() || undefined, - token: accountTokenFile.token, - tokenSource: accountTokenFile.tokenSource, - tokenStatus: accountTokenFile.tokenStatus, - configured: accountTokenFile.tokenStatus !== "missing", - config: merged, - }; - } - - const accountToken = inspectTokenValue({ cfg: params.cfg, value: accountConfig?.botToken }); - if (accountToken) { - return { - accountId, - enabled, - name: merged.name?.trim() || undefined, - token: accountToken.token, - tokenSource: accountToken.tokenSource, - tokenStatus: accountToken.tokenStatus, - configured: accountToken.tokenStatus !== "missing", - config: merged, - }; - } - - const channelTokenFile = inspectTokenFile(params.cfg.channels?.telegram?.tokenFile); - if (channelTokenFile) { - return { - accountId, - enabled, - name: merged.name?.trim() || undefined, - token: channelTokenFile.token, - tokenSource: channelTokenFile.tokenSource, - tokenStatus: channelTokenFile.tokenStatus, - configured: channelTokenFile.tokenStatus !== "missing", - config: merged, - }; - } - - const channelToken = inspectTokenValue({ - cfg: params.cfg, - value: params.cfg.channels?.telegram?.botToken, - }); - if (channelToken) { - return { - accountId, - enabled, - name: merged.name?.trim() || undefined, - token: channelToken.token, - tokenSource: channelToken.tokenSource, - tokenStatus: channelToken.tokenStatus, - configured: channelToken.tokenStatus !== "missing", - config: merged, - }; - } - - const envToken = - accountId === DEFAULT_ACCOUNT_ID - ? (params.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim() - : ""; - if (envToken) { - return { - accountId, - enabled, - name: merged.name?.trim() || undefined, - token: envToken, - tokenSource: "env", - tokenStatus: "available", - configured: true, - config: merged, - }; - } - - return { - accountId, - enabled, - name: merged.name?.trim() || undefined, - token: "", - tokenSource: "none", - tokenStatus: "missing", - configured: false, - config: merged, - }; -} - -export function inspectTelegramAccount(params: { - cfg: OpenClawConfig; - accountId?: string | null; - envToken?: string | null; -}): InspectedTelegramAccount { - return resolveAccountWithDefaultFallback({ - accountId: params.accountId, - normalizeAccountId, - resolvePrimary: (accountId) => - inspectTelegramAccountPrimary({ - cfg: params.cfg, - accountId, - envToken: params.envToken, - }), - hasCredential: (account) => account.tokenSource !== "none", - resolveDefaultAccountId: () => resolveDefaultTelegramAccountId(params.cfg), - }); -} diff --git a/src/cli/send-runtime/discord.ts b/src/cli/send-runtime/discord.ts deleted file mode 100644 index 103b678927f..00000000000 --- a/src/cli/send-runtime/discord.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js"; - -export const runtimeSend = createChannelOutboundRuntimeSend({ - channelId: "discord", - unavailableMessage: "Discord outbound adapter is unavailable.", -}); diff --git a/src/cli/send-runtime/imessage.ts b/src/cli/send-runtime/imessage.ts deleted file mode 100644 index 1ac217ce47b..00000000000 --- a/src/cli/send-runtime/imessage.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; -import { loadConfig } from "../../config/config.js"; - -type IMessageRuntimeSendOpts = { - config?: ReturnType; - mediaUrl?: string; - mediaLocalRoots?: readonly string[]; - accountId?: string; - replyToId?: string; -}; - -export const runtimeSend = { - sendMessage: async (to: string, text: string, opts: IMessageRuntimeSendOpts = {}) => { - const outbound = await loadChannelOutboundAdapter("imessage"); - if (!outbound?.sendText) { - throw new Error("iMessage outbound adapter is unavailable."); - } - return await outbound.sendText({ - cfg: opts.config ?? loadConfig(), - to, - text, - mediaUrl: opts.mediaUrl, - mediaLocalRoots: opts.mediaLocalRoots, - accountId: opts.accountId, - replyToId: opts.replyToId, - }); - }, -}; diff --git a/src/cli/send-runtime/signal.ts b/src/cli/send-runtime/signal.ts deleted file mode 100644 index c47ed80c134..00000000000 --- a/src/cli/send-runtime/signal.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js"; - -export const runtimeSend = createChannelOutboundRuntimeSend({ - channelId: "signal", - unavailableMessage: "Signal outbound adapter is unavailable.", -}); diff --git a/src/cli/send-runtime/slack.ts b/src/cli/send-runtime/slack.ts deleted file mode 100644 index 2e3f592d1a8..00000000000 --- a/src/cli/send-runtime/slack.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js"; - -export const runtimeSend = createChannelOutboundRuntimeSend({ - channelId: "slack", - unavailableMessage: "Slack outbound adapter is unavailable.", -}); diff --git a/src/cli/send-runtime/telegram.ts b/src/cli/send-runtime/telegram.ts deleted file mode 100644 index dd02555ff74..00000000000 --- a/src/cli/send-runtime/telegram.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js"; - -export const runtimeSend = createChannelOutboundRuntimeSend({ - channelId: "telegram", - unavailableMessage: "Telegram outbound adapter is unavailable.", -}); diff --git a/src/cli/send-runtime/whatsapp.ts b/src/cli/send-runtime/whatsapp.ts deleted file mode 100644 index a050fc81824..00000000000 --- a/src/cli/send-runtime/whatsapp.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createChannelOutboundRuntimeSend } from "./channel-outbound-send.js"; - -export const runtimeSend = createChannelOutboundRuntimeSend({ - channelId: "whatsapp", - unavailableMessage: "WhatsApp outbound adapter is unavailable.", -}); diff --git a/src/commands/channel-test-registry.ts b/src/commands/channel-test-registry.ts index ccbfebad7df..0cab68b4c34 100644 --- a/src/commands/channel-test-registry.ts +++ b/src/commands/channel-test-registry.ts @@ -1,121 +1,36 @@ -import { listBundledChannelPlugins } from "../channels/plugins/bundled.js"; -import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { + listBundledChannelPlugins, + setBundledChannelRuntime, +} from "../channels/plugins/bundled.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginRuntime } from "../plugins/runtime/index.js"; -import { loadBundledPluginTestApiSync } from "../test-utils/bundled-plugin-public-surface.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; -let googlechatPluginCache: ChannelPlugin | undefined; -let matrixPluginCache: ChannelPlugin | undefined; -let setMatrixRuntimeCache: ((runtime: PluginRuntime) => void) | undefined; -let msteamsPluginCache: ChannelPlugin | undefined; -let nostrPluginCache: ChannelPlugin | undefined; -let tlonPluginCache: ChannelPlugin | undefined; -let whatsappPluginCache: ChannelPlugin | undefined; - -const testPluginOverrides = new Map ChannelPlugin>([ - ["googlechat", getGooglechatPlugin], - ["matrix", getMatrixPlugin], - ["msteams", getMSTeamsPlugin], - ["nostr", getNostrPlugin], - ["tlon", getTlonPlugin], - ["whatsapp", getWhatsAppPlugin], -]); - -function getGooglechatPlugin(): ChannelPlugin { - if (!googlechatPluginCache) { - ({ googlechatPlugin: googlechatPluginCache } = loadBundledPluginTestApiSync<{ - googlechatPlugin: ChannelPlugin; - }>("googlechat")); - } - return googlechatPluginCache; -} - -function getMatrixPlugin(): ChannelPlugin { - if (!matrixPluginCache) { - ({ matrixPlugin: matrixPluginCache, setMatrixRuntime: setMatrixRuntimeCache } = - loadBundledPluginTestApiSync<{ - matrixPlugin: ChannelPlugin; - setMatrixRuntime: (runtime: PluginRuntime) => void; - }>("matrix")); - } - return matrixPluginCache; -} - -function getSetMatrixRuntime(): (runtime: PluginRuntime) => void { - if (!setMatrixRuntimeCache) { - void getMatrixPlugin(); - } - return setMatrixRuntimeCache!; -} - -function getMSTeamsPlugin(): ChannelPlugin { - if (!msteamsPluginCache) { - ({ msteamsPlugin: msteamsPluginCache } = loadBundledPluginTestApiSync<{ - msteamsPlugin: ChannelPlugin; - }>("msteams")); - } - return msteamsPluginCache; -} - -function getNostrPlugin(): ChannelPlugin { - if (!nostrPluginCache) { - ({ nostrPlugin: nostrPluginCache } = loadBundledPluginTestApiSync<{ - nostrPlugin: ChannelPlugin; - }>("nostr")); - } - return nostrPluginCache; -} - -function getTlonPlugin(): ChannelPlugin { - if (!tlonPluginCache) { - ({ tlonPlugin: tlonPluginCache } = loadBundledPluginTestApiSync<{ - tlonPlugin: ChannelPlugin; - }>("tlon")); - } - return tlonPluginCache; -} - -function getWhatsAppPlugin(): ChannelPlugin { - if (!whatsappPluginCache) { - ({ whatsappPlugin: whatsappPluginCache } = loadBundledPluginTestApiSync<{ - whatsappPlugin: ChannelPlugin; - }>("whatsapp")); - } - return whatsappPluginCache; -} - -function resolveChannelPluginsForTests(onlyPluginIds?: readonly string[]): ChannelPlugin[] { +function resolveChannelPluginsForTests(onlyPluginIds?: readonly string[]) { const scopedIds = onlyPluginIds ? new Set(onlyPluginIds) : null; - const selectedPlugins = new Map(); + return listBundledChannelPlugins().filter((plugin) => !scopedIds || scopedIds.has(plugin.id)); +} - for (const plugin of listBundledChannelPlugins()) { - if (scopedIds && !scopedIds.has(plugin.id)) { - continue; - } - selectedPlugins.set(plugin.id, plugin); - } - - for (const [pluginId, loadPlugin] of testPluginOverrides) { - if (scopedIds && !scopedIds.has(pluginId)) { - continue; - } - selectedPlugins.set(pluginId, loadPlugin()); - } - - return [...selectedPlugins.values()]; +function createChannelTestRuntime(): PluginRuntime { + return { + state: { + resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(), + }, + } as PluginRuntime; } export function setChannelPluginRegistryForTests(onlyPluginIds?: readonly string[]): void { - if (!onlyPluginIds || onlyPluginIds.includes("matrix")) { - getSetMatrixRuntime()({ - state: { - resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(), - }, - } as Parameters>[0]); + const plugins = resolveChannelPluginsForTests(onlyPluginIds); + const runtime = createChannelTestRuntime(); + for (const plugin of plugins) { + try { + setBundledChannelRuntime(plugin.id, runtime); + } catch { + // Most bundled channels do not need a runtime setter for contract tests. + } } - const channels = resolveChannelPluginsForTests(onlyPluginIds).map((plugin) => ({ + const channels = plugins.map((plugin) => ({ pluginId: plugin.id, plugin, source: "test" as const, diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 2f3a3d37c1a..55219cc7237 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -25,10 +25,6 @@ import { collectMissingDefaultAccountBindingWarnings, collectMissingExplicitDefaultAccountWarnings, } from "./doctor/shared/default-account-warnings.js"; -import { - collectMutableAllowlistWarnings, - scanMutableAllowlistEntries, -} from "./doctor/shared/mutable-allowlist.js"; import { collectDoctorPreviewWarnings } from "./doctor/shared/preview-warnings.js"; function hasLegacyInternalHookHandlers(raw: unknown): boolean { @@ -165,13 +161,9 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { }); } - const mutableAllowlistHits = scanMutableAllowlistEntries(candidate); - const mutableAllowlistWarnings = [ - ...(mutableAllowlistHits.length > 0 - ? collectMutableAllowlistWarnings(mutableAllowlistHits) - : []), - ...(await collectChannelDoctorMutableAllowlistWarnings({ cfg: candidate })), - ]; + const mutableAllowlistWarnings = await collectChannelDoctorMutableAllowlistWarnings({ + cfg: candidate, + }); if (mutableAllowlistWarnings.length > 0) { note(mutableAllowlistWarnings.join("\n"), "Doctor warnings"); } diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index bdde8e01431..9c41593a566 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -108,13 +108,12 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { } const keysToMove = Object.entries(rawChannel) - .filter( - ([key, value]) => - key !== "accounts" && - key !== "enabled" && - value !== undefined && - shouldMoveSingleAccountChannelKey({ channelKey: channelId, key }), - ) + .filter(([key, value]) => { + if (key === "accounts" || key === "enabled" || value === undefined) { + return false; + } + return shouldMoveSingleAccountChannelKey({ channelKey: channelId, key }); + }) .map(([key]) => key); if (keysToMove.length === 0) { continue; diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 025f42e9086..8f43fa09c03 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listBundledChannelPlugins } from "../channels/plugins/bundled.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; @@ -462,9 +463,10 @@ function shouldRequireOAuthDir(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boo if (!isRecord(channels)) { return false; } - // WhatsApp auth always uses the credentials tree. - if (isRecord(channels.whatsapp)) { - return true; + for (const plugin of listBundledChannelPlugins()) { + if (plugin.config.hasPersistedAuthState?.({ cfg, env })) { + return true; + } } // Pairing allowlists are persisted under credentials/-allowFrom.json. for (const [channelId, channelCfg] of Object.entries(channels)) { diff --git a/src/commands/doctor/channel-capabilities.ts b/src/commands/doctor/channel-capabilities.ts index e47c2eb8021..3a24d01cbb8 100644 --- a/src/commands/doctor/channel-capabilities.ts +++ b/src/commands/doctor/channel-capabilities.ts @@ -17,21 +17,6 @@ const DEFAULT_DOCTOR_CHANNEL_CAPABILITIES: DoctorChannelCapabilities = { warnOnEmptyGroupSenderAllowlist: true, }; -const DOCTOR_CHANNEL_CAPABILITIES: Record = { - imessage: { - dmAllowFromMode: "topOnly", - groupModel: "sender", - groupAllowFromFallbackToAllowFrom: false, - warnOnEmptyGroupSenderAllowlist: true, - }, - irc: { - dmAllowFromMode: "topOnly", - groupModel: "sender", - groupAllowFromFallbackToAllowFrom: false, - warnOnEmptyGroupSenderAllowlist: true, - }, -}; - export function getDoctorChannelCapabilities(channelName?: string): DoctorChannelCapabilities { if (!channelName) { return DEFAULT_DOCTOR_CHANNEL_CAPABILITIES; @@ -50,5 +35,5 @@ export function getDoctorChannelCapabilities(channelName?: string): DoctorChanne DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.warnOnEmptyGroupSenderAllowlist, }; } - return DOCTOR_CHANNEL_CAPABILITIES[channelName] ?? DEFAULT_DOCTOR_CHANNEL_CAPABILITIES; + return DEFAULT_DOCTOR_CHANNEL_CAPABILITIES; } diff --git a/src/commands/doctor/shared/mutable-allowlist.ts b/src/commands/doctor/shared/mutable-allowlist.ts deleted file mode 100644 index 0a4930d210d..00000000000 --- a/src/commands/doctor/shared/mutable-allowlist.ts +++ /dev/null @@ -1,202 +0,0 @@ -import type { OpenClawConfig } from "../../../config/config.js"; -import { collectProviderDangerousNameMatchingScopes } from "../../../config/dangerous-name-matching.js"; -import { - isGoogleChatMutableAllowEntry, - isIrcMutableAllowEntry, - isMSTeamsMutableAllowEntry, - isMattermostMutableAllowEntry, -} from "../../../security/mutable-allowlist-detectors.js"; -import { sanitizeForLog } from "../../../terminal/ansi.js"; -import { asObjectRecord } from "./object.js"; - -export type MutableAllowlistHit = { - channel: string; - path: string; - entry: string; - dangerousFlagPath: string; -}; - -function addMutableAllowlistHits(params: { - hits: MutableAllowlistHit[]; - pathLabel: string; - list: unknown; - detector: (entry: string) => boolean; - channel: string; - dangerousFlagPath: string; -}) { - if (!Array.isArray(params.list)) { - return; - } - for (const entry of params.list) { - const text = String(entry).trim(); - if (!text || text === "*") { - continue; - } - if (!params.detector(text)) { - continue; - } - params.hits.push({ - channel: params.channel, - path: params.pathLabel, - entry: text, - dangerousFlagPath: params.dangerousFlagPath, - }); - } -} - -export function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowlistHit[] { - const hits: MutableAllowlistHit[] = []; - - for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "googlechat")) { - if (scope.dangerousNameMatchingEnabled) { - continue; - } - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.groupAllowFrom`, - list: scope.account.groupAllowFrom, - detector: isGoogleChatMutableAllowEntry, - channel: "googlechat", - dangerousFlagPath: scope.dangerousFlagPath, - }); - const dm = asObjectRecord(scope.account.dm); - if (dm) { - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.dm.allowFrom`, - list: dm.allowFrom, - detector: isGoogleChatMutableAllowEntry, - channel: "googlechat", - dangerousFlagPath: scope.dangerousFlagPath, - }); - } - const groups = asObjectRecord(scope.account.groups); - if (!groups) { - continue; - } - for (const [groupKey, groupRaw] of Object.entries(groups)) { - const group = asObjectRecord(groupRaw); - if (!group) { - continue; - } - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.groups.${groupKey}.users`, - list: group.users, - detector: isGoogleChatMutableAllowEntry, - channel: "googlechat", - dangerousFlagPath: scope.dangerousFlagPath, - }); - } - } - - for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "msteams")) { - if (scope.dangerousNameMatchingEnabled) { - continue; - } - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.allowFrom`, - list: scope.account.allowFrom, - detector: isMSTeamsMutableAllowEntry, - channel: "msteams", - dangerousFlagPath: scope.dangerousFlagPath, - }); - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.groupAllowFrom`, - list: scope.account.groupAllowFrom, - detector: isMSTeamsMutableAllowEntry, - channel: "msteams", - dangerousFlagPath: scope.dangerousFlagPath, - }); - } - - for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "mattermost")) { - if (scope.dangerousNameMatchingEnabled) { - continue; - } - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.allowFrom`, - list: scope.account.allowFrom, - detector: isMattermostMutableAllowEntry, - channel: "mattermost", - dangerousFlagPath: scope.dangerousFlagPath, - }); - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.groupAllowFrom`, - list: scope.account.groupAllowFrom, - detector: isMattermostMutableAllowEntry, - channel: "mattermost", - dangerousFlagPath: scope.dangerousFlagPath, - }); - } - - for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "irc")) { - if (scope.dangerousNameMatchingEnabled) { - continue; - } - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.allowFrom`, - list: scope.account.allowFrom, - detector: isIrcMutableAllowEntry, - channel: "irc", - dangerousFlagPath: scope.dangerousFlagPath, - }); - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.groupAllowFrom`, - list: scope.account.groupAllowFrom, - detector: isIrcMutableAllowEntry, - channel: "irc", - dangerousFlagPath: scope.dangerousFlagPath, - }); - const groups = asObjectRecord(scope.account.groups); - if (!groups) { - continue; - } - for (const [groupKey, groupRaw] of Object.entries(groups)) { - const group = asObjectRecord(groupRaw); - if (!group) { - continue; - } - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.groups.${groupKey}.allowFrom`, - list: group.allowFrom, - detector: isIrcMutableAllowEntry, - channel: "irc", - dangerousFlagPath: scope.dangerousFlagPath, - }); - } - } - - return hits; -} - -export function collectMutableAllowlistWarnings(hits: MutableAllowlistHit[]): string[] { - if (hits.length === 0) { - return []; - } - const channels = Array.from(new Set(hits.map((hit) => hit.channel))).toSorted(); - const exampleLines = hits - .slice(0, 8) - .map((hit) => `- ${sanitizeForLog(hit.path)}: ${sanitizeForLog(hit.entry)}`); - const remaining = - hits.length > 8 ? `- +${hits.length - 8} more mutable allowlist entries.` : null; - const flagPaths = Array.from(new Set(hits.map((hit) => hit.dangerousFlagPath))); - const flagHint = - flagPaths.length === 1 - ? sanitizeForLog(flagPaths[0] ?? "") - : `${sanitizeForLog(flagPaths[0] ?? "")} (and ${flagPaths.length - 1} other scope flags)`; - return [ - `- Found ${hits.length} mutable allowlist ${hits.length === 1 ? "entry" : "entries"} across ${channels.join(", ")} while name matching is disabled by default.`, - ...exampleLines, - ...(remaining ? [remaining] : []), - `- Option A (break-glass): enable ${flagHint}=true to keep name/email/nick matching.`, - "- Option B (recommended): resolve names/emails/nicks to stable sender IDs and rewrite the allowlist entries.", - ]; -} diff --git a/src/commands/signal-install.ts b/src/commands/signal-install.ts deleted file mode 100644 index 0a329ecdde0..00000000000 --- a/src/commands/signal-install.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../plugins/signal-cli-install.js"; diff --git a/src/config/legacy.migrations.channels.ts b/src/config/legacy.migrations.channels.ts index 52d2d5decf5..3e505a22b3a 100644 --- a/src/config/legacy.migrations.channels.ts +++ b/src/config/legacy.migrations.channels.ts @@ -1,11 +1,3 @@ -import { - formatSlackStreamingBooleanMigrationMessage, - formatSlackStreamModeMigrationMessage, - resolveDiscordPreviewStreamMode, - resolveSlackNativeStreaming, - resolveSlackStreamingMode, - resolveTelegramPreviewStreamMode, -} from "./discord-preview-streaming.js"; import { defineLegacyConfigMigration, getRecord, @@ -78,41 +70,6 @@ function hasLegacyThreadBindingTtlInAnyChannel(value: unknown): boolean { }); } -function hasLegacyTelegramStreamingKeys(value: unknown): boolean { - const entry = getRecord(value); - if (!entry) { - return false; - } - return entry.streamMode !== undefined; -} - -function hasLegacyDiscordStreamingKeys(value: unknown): boolean { - const entry = getRecord(value); - if (!entry) { - return false; - } - return entry.streamMode !== undefined || typeof entry.streaming === "boolean"; -} - -function hasLegacySlackStreamingKeys(value: unknown): boolean { - const entry = getRecord(value); - if (!entry) { - return false; - } - return entry.streamMode !== undefined || typeof entry.streaming === "boolean"; -} - -function hasLegacyStreamingKeysInAccounts( - value: unknown, - matchEntry: (entry: Record) => boolean, -): boolean { - const accounts = getRecord(value); - if (!accounts) { - return false; - } - return Object.values(accounts).some((entry) => matchEntry(getRecord(entry) ?? {})); -} - const THREAD_BINDING_RULES: LegacyConfigRule[] = [ { path: ["session", "threadBindings"], @@ -128,45 +85,6 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [ }, ]; -const CHANNEL_STREAMING_RULES: LegacyConfigRule[] = [ - { - path: ["channels", "telegram"], - message: - "channels.telegram.streamMode is legacy; use channels.telegram.streaming instead (auto-migrated on load).", - match: (value) => hasLegacyTelegramStreamingKeys(value), - }, - { - path: ["channels", "telegram", "accounts"], - message: - "channels.telegram.accounts..streamMode is legacy; use channels.telegram.accounts..streaming instead (auto-migrated on load).", - match: (value) => hasLegacyStreamingKeysInAccounts(value, hasLegacyTelegramStreamingKeys), - }, - { - path: ["channels", "discord"], - message: - "channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming with enum values instead (auto-migrated on load).", - match: (value) => hasLegacyDiscordStreamingKeys(value), - }, - { - path: ["channels", "discord", "accounts"], - message: - "channels.discord.accounts..streamMode and boolean channels.discord.accounts..streaming are legacy; use channels.discord.accounts..streaming with enum values instead (auto-migrated on load).", - match: (value) => hasLegacyStreamingKeysInAccounts(value, hasLegacyDiscordStreamingKeys), - }, - { - path: ["channels", "slack"], - message: - "channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming with enum values instead (auto-migrated on load).", - match: (value) => hasLegacySlackStreamingKeys(value), - }, - { - path: ["channels", "slack", "accounts"], - message: - "channels.slack.accounts..streamMode and boolean channels.slack.accounts..streaming are legacy; use channels.slack.accounts..streaming with enum values instead (auto-migrated on load).", - match: (value) => hasLegacyStreamingKeysInAccounts(value, hasLegacySlackStreamingKeys), - }, -]; - export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ defineLegacyConfigMigration({ id: "thread-bindings.ttlHours->idleHours", @@ -221,108 +139,4 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ raw.channels = channels; }, }), - defineLegacyConfigMigration({ - id: "channels.streaming-keys->channels.streaming", - describe: - "Normalize legacy streaming keys to channels..streaming (Telegram/Discord/Slack)", - legacyRules: CHANNEL_STREAMING_RULES, - apply: (raw, changes) => { - const channels = getRecord(raw.channels); - if (!channels) { - return; - } - - const migrateProviderEntry = (params: { - provider: "telegram" | "discord" | "slack"; - entry: Record; - pathPrefix: string; - }) => { - const migrateCommonStreamingMode = ( - resolveMode: (entry: Record) => string, - ) => { - const hasLegacyStreamMode = params.entry.streamMode !== undefined; - const legacyStreaming = params.entry.streaming; - if (!hasLegacyStreamMode && typeof legacyStreaming !== "boolean") { - return false; - } - const resolved = resolveMode(params.entry); - params.entry.streaming = resolved; - if (hasLegacyStreamMode) { - delete params.entry.streamMode; - changes.push( - `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`, - ); - } - if (typeof legacyStreaming === "boolean") { - changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`); - } - return true; - }; - - const hasLegacyStreamMode = params.entry.streamMode !== undefined; - const legacyStreaming = params.entry.streaming; - const legacyNativeStreaming = params.entry.nativeStreaming; - - if (params.provider === "telegram") { - migrateCommonStreamingMode(resolveTelegramPreviewStreamMode); - return; - } - - if (params.provider === "discord") { - migrateCommonStreamingMode(resolveDiscordPreviewStreamMode); - return; - } - - if (!hasLegacyStreamMode && typeof legacyStreaming !== "boolean") { - return; - } - const resolvedStreaming = resolveSlackStreamingMode(params.entry); - const resolvedNativeStreaming = resolveSlackNativeStreaming(params.entry); - params.entry.streaming = resolvedStreaming; - params.entry.nativeStreaming = resolvedNativeStreaming; - if (hasLegacyStreamMode) { - delete params.entry.streamMode; - changes.push(formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming)); - } - if (typeof legacyStreaming === "boolean") { - changes.push( - formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming), - ); - } else if (typeof legacyNativeStreaming !== "boolean" && hasLegacyStreamMode) { - changes.push(`Set ${params.pathPrefix}.nativeStreaming → ${resolvedNativeStreaming}.`); - } - }; - - const migrateProvider = (provider: "telegram" | "discord" | "slack") => { - const providerEntry = getRecord(channels[provider]); - if (!providerEntry) { - return; - } - migrateProviderEntry({ - provider, - entry: providerEntry, - pathPrefix: `channels.${provider}`, - }); - const accounts = getRecord(providerEntry.accounts); - if (!accounts) { - return; - } - for (const [accountId, accountValue] of Object.entries(accounts)) { - const account = getRecord(accountValue); - if (!account) { - continue; - } - migrateProviderEntry({ - provider, - entry: account, - pathPrefix: `channels.${provider}.accounts.${accountId}`, - }); - } - }; - - migrateProvider("telegram"); - migrateProvider("discord"); - migrateProvider("slack"); - }, - }), ]; diff --git a/src/config/legacy.migrations.runtime.ts b/src/config/legacy.migrations.runtime.ts index 7f8fd81cdbd..f865a69efaa 100644 --- a/src/config/legacy.migrations.runtime.ts +++ b/src/config/legacy.migrations.runtime.ts @@ -231,21 +231,6 @@ function migrateLegacyTalkFields(raw: Record, changes: string[] ); } -function hasLegacyDiscordAccountTtsProviderKeys(value: unknown): boolean { - const accounts = getRecord(value); - if (!accounts) { - return false; - } - return Object.entries(accounts).some(([accountId, accountValue]) => { - if (isBlockedObjectKey(accountId)) { - return false; - } - const account = getRecord(accountValue); - const voice = getRecord(account?.voice); - return hasLegacyTtsProviderKeys(voice?.tts); - }); -} - function hasLegacyPluginEntryTtsProviderKeys(value: unknown): boolean { const entries = getRecord(value); if (!entries) { @@ -312,36 +297,12 @@ function migrateLegacyTtsConfig( } } -function resolveCompatibleDefaultGroupEntry(section: Record): { - groups: Record; - entry: Record; -} | null { - const existingGroups = section.groups; - if (existingGroups !== undefined && !getRecord(existingGroups)) { - return null; - } - const groups = getRecord(existingGroups) ?? {}; - const defaultKey = "*"; - const existingEntry = groups[defaultKey]; - if (existingEntry !== undefined && !getRecord(existingEntry)) { - return null; - } - const entry = getRecord(existingEntry) ?? {}; - return { groups, entry }; -} - const MEMORY_SEARCH_RULE: LegacyConfigRule = { path: ["memorySearch"], message: "top-level memorySearch was moved; use agents.defaults.memorySearch instead (auto-migrated on load).", }; -const GROUP_MENTIONS_ONLY_RULE: LegacyConfigRule = { - path: ["channels", "telegram", "groupMentionsOnly"], - message: - 'channels.telegram.groupMentionsOnly was removed; use channels.telegram.groups."*".requireMention instead (auto-migrated on load).', -}; - const GATEWAY_BIND_RULE: LegacyConfigRule = { path: ["gateway", "bind"], message: @@ -369,18 +330,6 @@ const LEGACY_TTS_RULES: LegacyConfigRule[] = [ "messages.tts. keys (openai/elevenlabs/microsoft/edge) are legacy; use messages.tts.providers. (auto-migrated on load).", match: (value) => hasLegacyTtsProviderKeys(value), }, - { - path: ["channels", "discord", "voice", "tts"], - message: - "channels.discord.voice.tts. keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.voice.tts.providers. (auto-migrated on load).", - match: (value) => hasLegacyTtsProviderKeys(value), - }, - { - path: ["channels", "discord", "accounts"], - message: - "channels.discord.accounts..voice.tts. keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.accounts..voice.tts.providers. (auto-migrated on load).", - match: (value) => hasLegacyDiscordAccountTtsProviderKeys(value), - }, { path: ["plugins", "entries"], message: @@ -531,53 +480,6 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [ ); }, }), - defineLegacyConfigMigration({ - // v2026.2.23 replaced channels.telegram.groupMentionsOnly with - // channels.telegram.groups."*".requireMention. Existing configs crash on - // startup because gateway auto-migration only runs for registered legacy - // keys, and this removed key previously fell through as an unknown field. - id: "channels.telegram.groupMentionsOnly->channels.telegram.groups.*.requireMention", - describe: - "Move channels.telegram.groupMentionsOnly to channels.telegram.groups.*.requireMention", - legacyRules: [GROUP_MENTIONS_ONLY_RULE], - apply: (raw, changes) => { - const channels = ensureRecord(raw, "channels"); - const telegram = getRecord(channels.telegram); - if (!telegram || telegram.groupMentionsOnly === undefined) { - return; - } - - const groupMentionsOnly = telegram.groupMentionsOnly; - const defaultGroupEntry = resolveCompatibleDefaultGroupEntry(telegram); - const defaultKey = "*"; - - if (!defaultGroupEntry) { - changes.push( - "Skipped channels.telegram.groupMentionsOnly migration because channels.telegram.groups already has an incompatible shape; fix remaining issues manually.", - ); - return; - } - - const { groups, entry } = defaultGroupEntry; - - if (entry.requireMention === undefined) { - entry.requireMention = groupMentionsOnly; - groups[defaultKey] = entry; - telegram.groups = groups; - changes.push( - 'Moved channels.telegram.groupMentionsOnly → channels.telegram.groups."*".requireMention.', - ); - } else { - changes.push( - 'Removed channels.telegram.groupMentionsOnly (channels.telegram.groups."*" already set).', - ); - } - - delete telegram.groupMentionsOnly; - channels.telegram = telegram; - raw.channels = channels; - }, - }), defineLegacyConfigMigration({ id: "memorySearch->agents.defaults.memorySearch", describe: "Move top-level memorySearch to agents.defaults.memorySearch", @@ -650,27 +552,6 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [ const messages = getRecord(raw.messages); migrateLegacyTtsConfig(getRecord(messages?.tts), "messages.tts", changes); - const channels = getRecord(raw.channels); - const discord = getRecord(channels?.discord); - const discordVoice = getRecord(discord?.voice); - migrateLegacyTtsConfig(getRecord(discordVoice?.tts), "channels.discord.voice.tts", changes); - - const discordAccounts = getRecord(discord?.accounts); - if (discordAccounts) { - for (const [accountId, accountValue] of Object.entries(discordAccounts)) { - if (isBlockedObjectKey(accountId)) { - continue; - } - const account = getRecord(accountValue); - const voice = getRecord(account?.voice); - migrateLegacyTtsConfig( - getRecord(voice?.tts), - `channels.discord.accounts.${accountId}.voice.tts`, - changes, - ); - } - } - const plugins = getRecord(raw.plugins); const pluginEntries = getRecord(plugins?.entries); if (!pluginEntries) { diff --git a/src/config/legacy.ts b/src/config/legacy.ts index deb4458d653..3223da24cdb 100644 --- a/src/config/legacy.ts +++ b/src/config/legacy.ts @@ -1,3 +1,7 @@ +import { + applyChannelDoctorCompatibilityMigrations, + collectChannelLegacyConfigRules, +} from "../channels/plugins/legacy-config.js"; import { LEGACY_CONFIG_MIGRATIONS } from "./legacy.migrations.js"; import { LEGACY_CONFIG_RULES } from "./legacy.rules.js"; import type { LegacyConfigIssue } from "./types.js"; @@ -21,7 +25,7 @@ export function findLegacyConfigIssues(raw: unknown, sourceRaw?: unknown): Legac const sourceRoot = sourceRaw && typeof sourceRaw === "object" ? (sourceRaw as Record) : root; const issues: LegacyConfigIssue[] = []; - for (const rule of LEGACY_CONFIG_RULES) { + for (const rule of [...LEGACY_CONFIG_RULES, ...collectChannelLegacyConfigRules()]) { const cursor = getPathValue(root, rule.path); if (cursor !== undefined && (!rule.match || rule.match(cursor, root))) { if (rule.requireSourceLiteral) { @@ -51,8 +55,10 @@ export function applyLegacyMigrations(raw: unknown): { for (const migration of LEGACY_CONFIG_MIGRATIONS) { migration.apply(next, changes); } + const compat = applyChannelDoctorCompatibilityMigrations(next); + changes.push(...compat.changes); if (changes.length === 0) { return { next: null, changes: [] }; } - return { next, changes }; + return { next: compat.next, changes }; } diff --git a/src/config/sessions/group.ts b/src/config/sessions/group.ts index 22fbd82f4d6..d0fc59fbc93 100644 --- a/src/config/sessions/group.ts +++ b/src/config/sessions/group.ts @@ -1,10 +1,25 @@ import type { MsgContext } from "../../auto-reply/templating.js"; +import { getBundledChannelContractSurfaces } from "../../channels/plugins/contract-surfaces.js"; import { normalizeHyphenSlug } from "../../shared/string-normalization.js"; import { listDeliverableMessageChannels } from "../../utils/message-channel.js"; import type { GroupKeyResolution } from "./types.js"; const getGroupSurfaces = () => new Set([...listDeliverableMessageChannels(), "webchat"]); +type LegacyGroupSessionSurface = { + resolveLegacyGroupSessionKey?: (ctx: MsgContext) => GroupKeyResolution | null; +}; + +function resolveLegacyGroupSessionKey(ctx: MsgContext): GroupKeyResolution | null { + for (const surface of getBundledChannelContractSurfaces() as LegacyGroupSessionSurface[]) { + const resolved = surface.resolveLegacyGroupSessionKey?.(ctx); + if (resolved) { + return resolved; + } + } + return null; +} + function normalizeGroupLabel(raw?: string) { return normalizeHyphenSlug(raw); } @@ -57,13 +72,13 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu const normalizedChatType = chatType === "channel" ? "channel" : chatType === "group" ? "group" : undefined; - const isWhatsAppGroupId = from.toLowerCase().endsWith("@g.us"); + const legacyResolution = resolveLegacyGroupSessionKey(ctx); const looksLikeGroup = normalizedChatType === "group" || normalizedChatType === "channel" || from.includes(":group:") || from.includes(":channel:") || - isWhatsAppGroupId; + legacyResolution !== null; if (!looksLikeGroup) { return null; } @@ -74,9 +89,11 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu const head = parts[0]?.trim().toLowerCase() ?? ""; const headIsSurface = head ? getGroupSurfaces().has(head) : false; - const provider = headIsSurface - ? head - : (providerHint ?? (isWhatsAppGroupId ? "whatsapp" : undefined)); + if (!headIsSurface && !providerHint && legacyResolution) { + return legacyResolution; + } + + const provider = headIsSurface ? head : (providerHint ?? legacyResolution?.channel); if (!provider) { return null; } diff --git a/src/config/telegram-custom-commands.ts b/src/config/telegram-custom-commands.ts deleted file mode 100644 index e7c316791d7..00000000000 --- a/src/config/telegram-custom-commands.ts +++ /dev/null @@ -1,95 +0,0 @@ -export const TELEGRAM_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/; - -export type TelegramCustomCommandInput = { - command?: string | null; - description?: string | null; -}; - -export type TelegramCustomCommandIssue = { - index: number; - field: "command" | "description"; - message: string; -}; - -export function normalizeTelegramCommandName(value: string): string { - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed; - return withoutSlash.trim().toLowerCase().replace(/-/g, "_"); -} - -export function normalizeTelegramCommandDescription(value: string): string { - return value.trim(); -} - -export function resolveTelegramCustomCommands(params: { - commands?: TelegramCustomCommandInput[] | null; - reservedCommands?: Set; - checkReserved?: boolean; - checkDuplicates?: boolean; -}): { - commands: Array<{ command: string; description: string }>; - issues: TelegramCustomCommandIssue[]; -} { - const entries = Array.isArray(params.commands) ? params.commands : []; - const reserved = params.reservedCommands ?? new Set(); - const checkReserved = params.checkReserved !== false; - const checkDuplicates = params.checkDuplicates !== false; - const seen = new Set(); - const resolved: Array<{ command: string; description: string }> = []; - const issues: TelegramCustomCommandIssue[] = []; - - for (let index = 0; index < entries.length; index += 1) { - const entry = entries[index]; - const normalized = normalizeTelegramCommandName(String(entry?.command ?? "")); - if (!normalized) { - issues.push({ - index, - field: "command", - message: "Telegram custom command is missing a command name.", - }); - continue; - } - if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) { - issues.push({ - index, - field: "command", - message: `Telegram custom command "/${normalized}" is invalid (use a-z, 0-9, underscore; max 32 chars).`, - }); - continue; - } - if (checkReserved && reserved.has(normalized)) { - issues.push({ - index, - field: "command", - message: `Telegram custom command "/${normalized}" conflicts with a native command.`, - }); - continue; - } - if (checkDuplicates && seen.has(normalized)) { - issues.push({ - index, - field: "command", - message: `Telegram custom command "/${normalized}" is duplicated.`, - }); - continue; - } - const description = normalizeTelegramCommandDescription(String(entry?.description ?? "")); - if (!description) { - issues.push({ - index, - field: "description", - message: `Telegram custom command "/${normalized}" is missing a description.`, - }); - continue; - } - if (checkDuplicates) { - seen.add(normalized); - } - resolved.push({ command: normalized, description }); - } - - return { commands: resolved, issues }; -} diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 43bfe813d8f..1f36059722d 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -5,7 +5,7 @@ import { normalizeTelegramCommandDescription, normalizeTelegramCommandName, resolveTelegramCustomCommands, -} from "./telegram-custom-commands.js"; +} from "../plugin-sdk/telegram-command-config.js"; import { ToolPolicySchema } from "./zod-schema.agent-runtime.js"; import { ChannelHealthMonitorSchema, diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index d09dd317bd2..cd436b5c322 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -9,6 +9,7 @@ import { } from "../../config/sessions/main-session.js"; import { sleepWithAbort } from "../../infra/backoff.js"; import type { OutboundDeliveryResult } from "../../infra/outbound/deliver.js"; +import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; import { logWarn, logError } from "../../logger.js"; import type { CronJob, CronRunTelemetry } from "../types.js"; import type { DeliveryTargetResolution } from "./delivery-target.js"; @@ -17,18 +18,8 @@ import type { RunCronAgentTurnResult } from "./run.js"; import { expectsSubagentFollowup, isLikelyInterimCronMessage } from "./subagent-followup-hints.js"; function normalizeDeliveryTarget(channel: string, to: string): string { - const channelLower = channel.trim().toLowerCase(); const toTrimmed = to.trim(); - if (channelLower === "feishu" || channelLower === "lark") { - const lowered = toTrimmed.toLowerCase(); - if (lowered.startsWith("user:")) { - return toTrimmed.slice("user:".length).trim(); - } - if (lowered.startsWith("chat:")) { - return toTrimmed.slice("chat:".length).trim(); - } - } - return toTrimmed; + return normalizeTargetForProvider(channel, toTrimmed) ?? toTrimmed; } export function matchesMessagingToolDeliveryTarget( diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index ccebfa26ff1..73abfd587b8 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -152,9 +152,6 @@ export async function setupChannels( void loadScopedChannelPlugin(channel, entry.pluginId); } }; - if (options?.whatsappAccountId?.trim()) { - accountOverrides.whatsapp = options.whatsappAccountId.trim(); - } preloadConfiguredExternalPlugins(); const { diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index c329047f854..11a08a41838 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -11,6 +11,7 @@ import type { WebSocketServer } from "ws"; import { resolveAgentAvatar } from "../agents/identity-avatar.js"; import { CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; +import { listBundledChannelPlugins } from "../channels/plugins/bundled.js"; import { loadConfig } from "../config/config.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveHookExternalContentSource as resolveHookExternalContentSourceFromSession } from "../security/external-content.js"; @@ -132,62 +133,19 @@ const GATEWAY_PROBE_STATUS_BY_PATH = new Map([ ["/ready", "ready"], ["/readyz", "ready"], ]); -const MATTERMOST_SLASH_CALLBACK_PATH = "/api/channels/mattermost/command"; - -function resolveMattermostSlashCallbackPaths( +function resolvePluginGatewayAuthBypassPaths( configSnapshot: ReturnType, ): Set { - const callbackPaths = new Set([MATTERMOST_SLASH_CALLBACK_PATH]); - const isMattermostCommandCallbackPath = (path: string): boolean => - path === MATTERMOST_SLASH_CALLBACK_PATH || path.startsWith("/api/channels/mattermost/"); - - const normalizeCallbackPath = (value: unknown): string => { - const trimmed = typeof value === "string" ? value.trim() : ""; - if (!trimmed) { - return MATTERMOST_SLASH_CALLBACK_PATH; - } - return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - }; - - const tryAddCallbackUrlPath = (rawUrl: unknown) => { - if (typeof rawUrl !== "string") { - return; - } - const trimmed = rawUrl.trim(); - if (!trimmed) { - return; - } - try { - const pathname = new URL(trimmed).pathname; - if (pathname && isMattermostCommandCallbackPath(pathname)) { - callbackPaths.add(pathname); + const paths = new Set(); + for (const plugin of listBundledChannelPlugins()) { + for (const path of plugin.gateway?.resolveGatewayAuthBypassPaths?.({ cfg: configSnapshot }) ?? + []) { + if (typeof path === "string" && path.trim()) { + paths.add(path.trim()); } - } catch { - // Ignore invalid callback URLs in config and keep default path behavior. } - }; - - const mmRaw = configSnapshot.channels?.mattermost as Record | undefined; - const addMmCommands = (raw: unknown) => { - if (raw == null || typeof raw !== "object") { - return; - } - const commands = raw as Record; - const callbackPath = normalizeCallbackPath(commands.callbackPath); - if (isMattermostCommandCallbackPath(callbackPath)) { - callbackPaths.add(callbackPath); - } - tryAddCallbackUrlPath(commands.callbackUrl); - }; - - addMmCommands(mmRaw?.commands); - const accountsRaw = (mmRaw?.accounts ?? {}) as Record; - for (const accountId of Object.keys(accountsRaw)) { - const accountCfg = accountsRaw[accountId] as Record | undefined; - addMmCommands(accountCfg?.commands); } - - return callbackPaths; + return paths; } function shouldEnforceDefaultPluginGatewayAuth(pathContext: PluginRoutePathContext): boolean { @@ -336,7 +294,7 @@ function buildPluginRequestStages(params: { req: IncomingMessage; res: ServerResponse; requestPath: string; - mattermostSlashCallbackPaths: ReadonlySet; + gatewayAuthBypassPaths: ReadonlySet; pluginPathContext: PluginRoutePathContext | null; handlePluginRequest?: PluginHttpRequestHandler; shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean; @@ -353,7 +311,7 @@ function buildPluginRequestStages(params: { { name: "plugin-auth", run: async () => { - if (params.mattermostSlashCallbackPaths.has(params.requestPath)) { + if (params.gatewayAuthBypassPaths.has(params.requestPath)) { return false; } const pathContext = @@ -833,7 +791,7 @@ export function createGatewayHttpServer(opts: { req.url = scopedCanvas.rewrittenUrl; } const requestPath = new URL(req.url ?? "/", "http://localhost").pathname; - const mattermostSlashCallbackPaths = resolveMattermostSlashCallbackPaths(configSnapshot); + const gatewayAuthBypassPaths = resolvePluginGatewayAuthBypassPaths(configSnapshot); const pluginPathContext = handlePluginRequest ? resolvePluginRoutePathContext(requestPath) : null; @@ -964,7 +922,7 @@ export function createGatewayHttpServer(opts: { req, res, requestPath, - mattermostSlashCallbackPaths, + gatewayAuthBypassPaths, pluginPathContext, handlePluginRequest, shouldEnforcePluginGatewayAuth, diff --git a/src/generated/plugin-sdk-facade-type-map.generated.ts b/src/generated/plugin-sdk-facade-type-map.generated.ts index 2f17ab05b9d..1140643523f 100644 --- a/src/generated/plugin-sdk-facade-type-map.generated.ts +++ b/src/generated/plugin-sdk-facade-type-map.generated.ts @@ -557,15 +557,6 @@ export interface PluginSdkFacadeTypeMap { }; types: {}; }; - "whatsapp-targets": { - module: typeof import("@openclaw/whatsapp/targets.js"); - sourceModules: { - source1: { - module: typeof import("@openclaw/whatsapp/targets.js"); - }; - }; - types: {}; - }; "whatsapp-surface": { module: typeof import("@openclaw/whatsapp/api.js"); sourceModules: { diff --git a/src/index.ts b/src/index.ts index f336a9d6b6e..e37efe281f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,6 @@ type LibraryExports = typeof import("./library.js"); // These bindings are populated only for library consumers. The CLI entry stays // on the lean path and must not read them while running as main. -export let assertWebChannel: LibraryExports["assertWebChannel"]; export let applyTemplate: LibraryExports["applyTemplate"]; export let createDefaultDeps: LibraryExports["createDefaultDeps"]; export let deriveSessionKey: LibraryExports["deriveSessionKey"]; @@ -34,7 +33,6 @@ export let resolveStorePath: LibraryExports["resolveStorePath"]; export let runCommandWithTimeout: LibraryExports["runCommandWithTimeout"]; export let runExec: LibraryExports["runExec"]; export let saveSessionStore: LibraryExports["saveSessionStore"]; -export let toWhatsappJid: LibraryExports["toWhatsappJid"]; export let waitForever: LibraryExports["waitForever"]; async function loadLegacyCliDeps(): Promise { @@ -61,7 +59,6 @@ const isMain = isMainModule({ if (!isMain) { ({ - assertWebChannel, applyTemplate, createDefaultDeps, deriveSessionKey, @@ -81,7 +78,6 @@ if (!isMain) { runCommandWithTimeout, runExec, saveSessionStore, - toWhatsappJid, waitForever, } = await import("./library.js")); } diff --git a/src/infra/matrix-config-helpers.ts b/src/infra/matrix-config-helpers.ts deleted file mode 100644 index 3ce29c9e371..00000000000 --- a/src/infra/matrix-config-helpers.ts +++ /dev/null @@ -1,264 +0,0 @@ -import crypto from "node:crypto"; -import path from "node:path"; -import type { OpenClawConfig } from "../config/config.js"; -import { - listCombinedAccountIds, - listConfiguredAccountIds, - resolveListedDefaultAccountId, - resolveNormalizedAccountEntry, -} from "../plugin-sdk/account-core.js"; -import { - DEFAULT_ACCOUNT_ID, - normalizeAccountId, - normalizeOptionalAccountId, -} from "../routing/session-key.js"; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -const MATRIX_SCOPED_ENV_SUFFIXES = [ - "HOMESERVER", - "USER_ID", - "ACCESS_TOKEN", - "PASSWORD", - "DEVICE_ID", - "DEVICE_NAME", -] as const; -const MATRIX_GLOBAL_ENV_KEYS = MATRIX_SCOPED_ENV_SUFFIXES.map((suffix) => `MATRIX_${suffix}`); -const MATRIX_SCOPED_ENV_RE = new RegExp(`^MATRIX_(.+)_(${MATRIX_SCOPED_ENV_SUFFIXES.join("|")})$`); - -export function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record | null { - return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null; -} - -export function findMatrixAccountEntry( - cfg: OpenClawConfig, - accountId: string, -): Record | null { - const channel = resolveMatrixChannelConfig(cfg); - if (!channel) { - return null; - } - const accounts = isRecord(channel.accounts) ? channel.accounts : null; - if (!accounts) { - return null; - } - const entry = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId); - return isRecord(entry) ? entry : null; -} - -export function resolveMatrixEnvAccountToken(accountId: string): string { - return Array.from(normalizeAccountId(accountId)) - .map((char) => - /[a-z0-9]/.test(char) - ? char.toUpperCase() - : `_X${char.codePointAt(0)?.toString(16).toUpperCase() ?? "00"}_`, - ) - .join(""); -} - -export function getMatrixScopedEnvVarNames(accountId: string): { - homeserver: string; - userId: string; - accessToken: string; - password: string; - deviceId: string; - deviceName: string; -} { - const token = resolveMatrixEnvAccountToken(accountId); - return { - homeserver: `MATRIX_${token}_HOMESERVER`, - userId: `MATRIX_${token}_USER_ID`, - accessToken: `MATRIX_${token}_ACCESS_TOKEN`, - password: `MATRIX_${token}_PASSWORD`, - deviceId: `MATRIX_${token}_DEVICE_ID`, - deviceName: `MATRIX_${token}_DEVICE_NAME`, - }; -} - -function decodeMatrixEnvAccountToken(token: string): string | undefined { - let decoded = ""; - for (let index = 0; index < token.length; ) { - const hexEscape = /^_X([0-9A-F]+)_/.exec(token.slice(index)); - if (hexEscape) { - const hex = hexEscape[1]; - const codePoint = hex ? Number.parseInt(hex, 16) : Number.NaN; - if (!Number.isFinite(codePoint)) { - return undefined; - } - decoded += String.fromCodePoint(codePoint); - index += hexEscape[0].length; - continue; - } - const char = token[index]; - if (!char || !/[A-Z0-9]/.test(char)) { - return undefined; - } - decoded += char.toLowerCase(); - index += 1; - } - const normalized = normalizeOptionalAccountId(decoded); - if (!normalized) { - return undefined; - } - return resolveMatrixEnvAccountToken(normalized) === token ? normalized : undefined; -} - -export function listMatrixEnvAccountIds(env: NodeJS.ProcessEnv = process.env): string[] { - const ids = new Set(); - for (const key of MATRIX_GLOBAL_ENV_KEYS) { - if (typeof env[key] === "string" && env[key]?.trim()) { - ids.add(DEFAULT_ACCOUNT_ID); - break; - } - } - for (const key of Object.keys(env)) { - const match = MATRIX_SCOPED_ENV_RE.exec(key); - if (!match) { - continue; - } - const accountId = decodeMatrixEnvAccountToken(match[1]); - if (accountId) { - ids.add(accountId); - } - } - return Array.from(ids).toSorted((a, b) => a.localeCompare(b)); -} - -export function resolveConfiguredMatrixAccountIds( - cfg: OpenClawConfig, - env: NodeJS.ProcessEnv = process.env, -): string[] { - const channel = resolveMatrixChannelConfig(cfg); - return listCombinedAccountIds({ - configuredAccountIds: listConfiguredAccountIds({ - accounts: channel && isRecord(channel.accounts) ? channel.accounts : undefined, - normalizeAccountId, - }), - additionalAccountIds: listMatrixEnvAccountIds(env), - fallbackAccountIdWhenEmpty: channel ? DEFAULT_ACCOUNT_ID : undefined, - }); -} - -export function resolveMatrixDefaultOrOnlyAccountId( - cfg: OpenClawConfig, - env: NodeJS.ProcessEnv = process.env, -): string { - const channel = resolveMatrixChannelConfig(cfg); - if (!channel) { - return DEFAULT_ACCOUNT_ID; - } - const configuredDefault = normalizeOptionalAccountId( - typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined, - ); - return resolveListedDefaultAccountId({ - accountIds: resolveConfiguredMatrixAccountIds(cfg, env), - configuredDefaultAccountId: configuredDefault, - ambiguousFallbackAccountId: DEFAULT_ACCOUNT_ID, - }); -} - -export function requiresExplicitMatrixDefaultAccount( - cfg: OpenClawConfig, - env: NodeJS.ProcessEnv = process.env, -): boolean { - const channel = resolveMatrixChannelConfig(cfg); - if (!channel) { - return false; - } - const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env); - if (configuredAccountIds.length <= 1) { - return false; - } - const configuredDefault = normalizeOptionalAccountId( - typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined, - ); - return !(configuredDefault && configuredAccountIds.includes(configuredDefault)); -} - -function sanitizeMatrixPathSegment(value: string): string { - const cleaned = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "_") - .replace(/^_+|_+$/g, ""); - return cleaned || "unknown"; -} - -function resolveMatrixHomeserverKey(homeserver: string): string { - try { - const url = new URL(homeserver); - if (url.host) { - return sanitizeMatrixPathSegment(url.host); - } - } catch { - // fall through - } - return sanitizeMatrixPathSegment(homeserver); -} - -function hashMatrixAccessToken(accessToken: string): string { - return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16); -} - -function resolveMatrixCredentialsFilename(accountId?: string | null): string { - const normalized = normalizeAccountId(accountId); - return normalized === DEFAULT_ACCOUNT_ID ? "credentials.json" : `credentials-${normalized}.json`; -} - -function resolveMatrixCredentialsDir(stateDir: string): string { - return path.join(stateDir, "credentials", "matrix"); -} - -export function resolveMatrixCredentialsPath(params: { - stateDir: string; - accountId?: string | null; -}): string { - return path.join( - resolveMatrixCredentialsDir(params.stateDir), - resolveMatrixCredentialsFilename(params.accountId), - ); -} - -export function resolveMatrixLegacyFlatStoragePaths(stateDir: string): { - rootDir: string; - storagePath: string; - cryptoPath: string; -} { - const rootDir = path.join(stateDir, "matrix"); - return { - rootDir, - storagePath: path.join(rootDir, "bot-storage.json"), - cryptoPath: path.join(rootDir, "crypto"), - }; -} - -export function resolveMatrixAccountStorageRoot(params: { - stateDir: string; - homeserver: string; - userId: string; - accessToken: string; - accountId?: string | null; -}): { - rootDir: string; - accountKey: string; - tokenHash: string; -} { - const accountKey = sanitizeMatrixPathSegment(params.accountId ?? DEFAULT_ACCOUNT_ID); - const userKey = sanitizeMatrixPathSegment(params.userId); - const serverKey = resolveMatrixHomeserverKey(params.homeserver); - const tokenHash = hashMatrixAccessToken(params.accessToken); - return { - rootDir: path.join( - params.stateDir, - "matrix", - "accounts", - accountKey, - `${serverKey}__${userKey}`, - tokenHash, - ), - accountKey, - tokenHash, - }; -} diff --git a/src/infra/matrix-plugin-helper.ts b/src/infra/matrix-plugin-helper.ts deleted file mode 100644 index a0a78eb4d72..00000000000 --- a/src/infra/matrix-plugin-helper.ts +++ /dev/null @@ -1,198 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import { createJiti } from "jiti"; -import type { OpenClawConfig } from "../config/config.js"; -import { - loadPluginManifestRegistry, - type PluginManifestRecord, -} from "../plugins/manifest-registry.js"; -import { shouldPreferNativeJiti } from "../plugins/sdk-alias.js"; -import { openBoundaryFileSync } from "./boundary-file-read.js"; - -const MATRIX_PLUGIN_ID = "matrix"; -const MATRIX_HELPER_CANDIDATES = [ - "legacy-crypto-inspector.ts", - "legacy-crypto-inspector.js", - path.join("dist", "legacy-crypto-inspector.js"), -] as const; - -export const MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE = - "Legacy Matrix encrypted state was detected, but the Matrix plugin helper is unavailable. Install or repair @openclaw/matrix so OpenClaw can inspect the old rust crypto store before upgrading."; - -type MatrixLegacyCryptoInspectorParams = { - cryptoRootDir: string; - userId: string; - deviceId: string; - log?: (message: string) => void; -}; - -type MatrixLegacyCryptoInspectorResult = { - deviceId: string | null; - roomKeyCounts: { - total: number; - backedUp: number; - } | null; - backupVersion: string | null; - decryptionKeyBase64: string | null; -}; - -export type MatrixLegacyCryptoInspector = ( - params: MatrixLegacyCryptoInspectorParams, -) => Promise; - -function resolveMatrixPluginRecord(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - workspaceDir?: string; -}): PluginManifestRecord | null { - const registry = loadPluginManifestRegistry({ - config: params.cfg, - workspaceDir: params.workspaceDir, - env: params.env, - }); - return registry.plugins.find((plugin) => plugin.id === MATRIX_PLUGIN_ID) ?? null; -} - -type MatrixLegacyCryptoInspectorPathResolution = - | { status: "ok"; helperPath: string } - | { status: "missing" } - | { status: "unsafe"; candidatePath: string }; - -function resolveMatrixLegacyCryptoInspectorPath(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - workspaceDir?: string; -}): MatrixLegacyCryptoInspectorPathResolution { - const plugin = resolveMatrixPluginRecord(params); - if (!plugin) { - return { status: "missing" }; - } - for (const relativePath of MATRIX_HELPER_CANDIDATES) { - const candidatePath = path.join(plugin.rootDir, relativePath); - const opened = openBoundaryFileSync({ - absolutePath: candidatePath, - rootPath: plugin.rootDir, - boundaryLabel: "plugin root", - rejectHardlinks: plugin.origin !== "bundled", - allowedType: "file", - }); - if (opened.ok) { - fs.closeSync(opened.fd); - return { status: "ok", helperPath: opened.path }; - } - if (opened.reason !== "path") { - return { status: "unsafe", candidatePath }; - } - } - return { status: "missing" }; -} - -export function isMatrixLegacyCryptoInspectorAvailable(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - workspaceDir?: string; -}): boolean { - return resolveMatrixLegacyCryptoInspectorPath(params).status === "ok"; -} - -let jitiLoader: ReturnType | null = null; -const inspectorCache = new Map>(); - -function getJiti() { - if (jitiLoader) { - return jitiLoader; - } - - jitiLoader = createJiti(import.meta.url, { - interopDefault: false, - tryNative: false, - extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], - }); - return jitiLoader; -} - -function canRetryWithJiti(error: unknown): boolean { - if (!error || typeof error !== "object") { - return false; - } - const code = "code" in error ? (error as { code?: unknown }).code : undefined; - return code === "ERR_MODULE_NOT_FOUND" || code === "ERR_UNKNOWN_FILE_EXTENSION"; -} - -function isObjectRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function resolveInspectorExport(loaded: unknown): MatrixLegacyCryptoInspector | null { - if (!isObjectRecord(loaded)) { - return null; - } - const directInspector = loaded.inspectLegacyMatrixCryptoStore; - if (typeof directInspector === "function") { - return directInspector as MatrixLegacyCryptoInspector; - } - const directDefault = loaded.default; - if (typeof directDefault === "function") { - return directDefault as MatrixLegacyCryptoInspector; - } - if (!isObjectRecord(directDefault)) { - return null; - } - const nestedInspector = directDefault.inspectLegacyMatrixCryptoStore; - return typeof nestedInspector === "function" - ? (nestedInspector as MatrixLegacyCryptoInspector) - : null; -} - -export async function loadMatrixLegacyCryptoInspector(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - workspaceDir?: string; -}): Promise { - const resolution = resolveMatrixLegacyCryptoInspectorPath(params); - if (resolution.status === "missing") { - throw new Error(MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE); - } - if (resolution.status === "unsafe") { - throw new Error( - `Matrix plugin helper path is unsafe: ${resolution.candidatePath}. Reinstall @openclaw/matrix and try again.`, - ); - } - const helperPath = resolution.helperPath; - - const cached = inspectorCache.get(helperPath); - if (cached) { - return await cached; - } - - const pending = (async () => { - let loaded: unknown; - if (shouldPreferNativeJiti(helperPath)) { - try { - loaded = await import(pathToFileURL(helperPath).href); - } catch (error) { - if (!canRetryWithJiti(error)) { - throw error; - } - loaded = getJiti()(helperPath); - } - } else { - loaded = getJiti()(helperPath); - } - const inspectLegacyMatrixCryptoStore = resolveInspectorExport(loaded); - if (!inspectLegacyMatrixCryptoStore) { - throw new Error( - `Matrix plugin helper at ${helperPath} does not export inspectLegacyMatrixCryptoStore(). Reinstall @openclaw/matrix and try again.`, - ); - } - return inspectLegacyMatrixCryptoStore; - })(); - inspectorCache.set(helperPath, pending); - try { - return await pending; - } catch (err) { - inspectorCache.delete(helperPath); - throw err; - } -} diff --git a/src/infra/outbound/deliver.test-helpers.ts b/src/infra/outbound/deliver.test-helpers.ts index 9e0e75105af..b5c4329b65e 100644 --- a/src/infra/outbound/deliver.test-helpers.ts +++ b/src/infra/outbound/deliver.test-helpers.ts @@ -1,11 +1,11 @@ import { vi } from "vitest"; +import { createIMessageTestPlugin } from "../../../test/helpers/channels/imessage-test-plugin.js"; import type { OpenClawConfig } from "../../config/config.js"; import { releasePinnedPluginChannelRegistry, setActivePluginRegistry, } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; import type { DeliverOutboundPayloadsParams, OutboundDeliveryResult } from "./deliver.js"; import { diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index 6a0c21e6a60..e51f52d71a2 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -1,3 +1,4 @@ +import { getBundledChannelContractSurfaceEntries } from "../../channels/plugins/contract-surfaces.js"; import type { ChannelMessageActionName } from "../../channels/plugins/types.js"; export type MessageActionTargetMode = "to" | "channelId" | "none"; @@ -64,17 +65,11 @@ export const MESSAGE_ACTION_TARGET_MODE: Record> = { - read: { aliases: ["messageId"], channels: ["feishu"] }, unsend: { aliases: ["messageId"] }, edit: { aliases: ["messageId"] }, - pin: { aliases: ["messageId"], channels: ["feishu"] }, - unpin: { aliases: ["messageId"], channels: ["feishu"] }, - "list-pins": { aliases: ["chatId"], channels: ["feishu"] }, - "channel-info": { aliases: ["chatId"], channels: ["feishu"] }, react: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, renameGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, setGroupIcon: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, @@ -83,6 +78,45 @@ const ACTION_TARGET_ALIASES: Partial>; +}; + +function listChannelMessageActionAliasSurfaces(): Array<{ + pluginId: string; + surface: ChannelMessageActionAliasSurface; +}> { + return getBundledChannelContractSurfaceEntries() as Array<{ + pluginId: string; + surface: ChannelMessageActionAliasSurface; + }>; +} + +function listActionTargetAliasSpecs( + action: ChannelMessageActionName, + channel?: string, +): ActionTargetAliasSpec[] { + const specs: ActionTargetAliasSpec[] = []; + const coreSpec = ACTION_TARGET_ALIASES[action]; + if (coreSpec) { + specs.push(coreSpec); + } + const normalizedChannel = channel?.trim().toLowerCase(); + if (!normalizedChannel) { + return specs; + } + for (const entry of listChannelMessageActionAliasSurfaces()) { + if (entry.pluginId !== normalizedChannel) { + continue; + } + const channelSpec = entry.surface.messageActionTargetAliases?.[action]; + if (channelSpec) { + specs.push(channelSpec); + } + } + return specs; +} + export function actionRequiresTarget(action: ChannelMessageActionName): boolean { return MESSAGE_ACTION_TARGET_MODE[action] !== "none"; } @@ -100,24 +134,20 @@ export function actionHasTarget( if (channelId) { return true; } - const spec = ACTION_TARGET_ALIASES[action]; - if (!spec) { + const specs = listActionTargetAliasSpecs(action, options?.channel); + if (specs.length === 0) { return false; } - if ( - spec.channels && - (!options?.channel || !spec.channels.includes(options.channel.trim().toLowerCase())) - ) { - return false; - } - return spec.aliases.some((alias) => { - const value = params[alias]; - if (typeof value === "string") { - return value.trim().length > 0; - } - if (typeof value === "number") { - return Number.isFinite(value); - } - return false; - }); + return specs.some((spec) => + spec.aliases.some((alias) => { + const value = params[alias]; + if (typeof value === "string") { + return value.trim().length > 0; + } + if (typeof value === "number") { + return Number.isFinite(value); + } + return false; + }), + ); } diff --git a/src/infra/retry-policy.ts b/src/infra/retry-policy.ts index e28142b117f..e8b8f403399 100644 --- a/src/infra/retry-policy.ts +++ b/src/infra/retry-policy.ts @@ -4,31 +4,31 @@ import { type RetryConfig, resolveRetryConfig, retryAsync } from "./retry.js"; export type RetryRunner = (fn: () => Promise, label?: string) => Promise; -export const TELEGRAM_RETRY_DEFAULTS = { +export const CHANNEL_API_RETRY_DEFAULTS = { attempts: 3, minDelayMs: 400, maxDelayMs: 30_000, jitter: 0.1, }; -const TELEGRAM_RETRY_RE = /429|timeout|connect|reset|closed|unavailable|temporarily/i; +const CHANNEL_API_RETRY_RE = /429|timeout|connect|reset|closed|unavailable|temporarily/i; const log = createSubsystemLogger("retry-policy"); -function resolveTelegramShouldRetry(params: { +function resolveChannelApiShouldRetry(params: { shouldRetry?: (err: unknown) => boolean; strictShouldRetry?: boolean; }) { if (!params.shouldRetry) { - return (err: unknown) => TELEGRAM_RETRY_RE.test(formatErrorMessage(err)); + return (err: unknown) => CHANNEL_API_RETRY_RE.test(formatErrorMessage(err)); } if (params.strictShouldRetry) { return params.shouldRetry; } return (err: unknown) => - params.shouldRetry?.(err) || TELEGRAM_RETRY_RE.test(formatErrorMessage(err)); + params.shouldRetry?.(err) || CHANNEL_API_RETRY_RE.test(formatErrorMessage(err)); } -function getTelegramRetryAfterMs(err: unknown): number | undefined { +function getChannelApiRetryAfterMs(err: unknown): number | undefined { if (!err || typeof err !== "object") { return undefined; } @@ -81,36 +81,36 @@ export function createRateLimitRetryRunner(params: { }); } -export function createTelegramRetryRunner(params: { +export function createChannelApiRetryRunner(params: { retry?: RetryConfig; configRetry?: RetryConfig; verbose?: boolean; shouldRetry?: (err: unknown) => boolean; /** * When true, the custom shouldRetry predicate is used exclusively — - * the default TELEGRAM_RETRY_RE fallback regex is NOT OR'd in. + * the default channel API fallback regex is NOT OR'd in. * Use this for non-idempotent operations (e.g. sendMessage) where * the regex fallback would cause duplicate message delivery. */ strictShouldRetry?: boolean; }): RetryRunner { - const retryConfig = resolveRetryConfig(TELEGRAM_RETRY_DEFAULTS, { + const retryConfig = resolveRetryConfig(CHANNEL_API_RETRY_DEFAULTS, { ...params.configRetry, ...params.retry, }); - const shouldRetry = resolveTelegramShouldRetry(params); + const shouldRetry = resolveChannelApiShouldRetry(params); return (fn: () => Promise, label?: string) => retryAsync(fn, { ...retryConfig, label, shouldRetry, - retryAfterMs: getTelegramRetryAfterMs, + retryAfterMs: getChannelApiRetryAfterMs, onRetry: params.verbose ? (info) => { const maxRetries = Math.max(1, info.maxAttempts - 1); log.warn( - `telegram send retry ${info.attempt}/${maxRetries} for ${info.label ?? label ?? "request"} in ${info.delayMs}ms: ${formatErrorMessage(info.err)}`, + `channel send retry ${info.attempt}/${maxRetries} for ${info.label ?? label ?? "request"} in ${info.delayMs}ms: ${formatErrorMessage(info.err)}`, ); } : undefined, diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index e888fa0ffbd..eb8dcf6dcb9 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -2,7 +2,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { resolveDefaultTelegramAccountId } from "../channels/read-only-account-inspect.telegram.js"; +import { listBundledChannelPlugins } from "../channels/plugins/bundled.js"; +import { getBundledChannelContractSurfaces } from "../channels/plugins/contract-surfaces.js"; +import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveLegacyStateDirs, @@ -15,10 +17,8 @@ import { saveSessionStore } from "../config/sessions.js"; import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js"; import type { SessionScope } from "../config/sessions/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { resolveChannelAllowFromPath } from "../pairing/pairing-store.js"; import { buildAgentMainSessionKey, - DEFAULT_ACCOUNT_ID, DEFAULT_AGENT_ID, DEFAULT_MAIN_KEY, normalizeAgentId, @@ -31,7 +31,6 @@ import { ensureDir, existsDir, fileExists, - isLegacyWhatsAppAuthFile, readSessionStoreJson5, type SessionEntryLike, safeReadDir, @@ -56,24 +55,13 @@ export type LegacyStateDetection = { targetDir: string; hasLegacy: boolean; }; - whatsappAuth: { - legacyDir: string; - targetDir: string; + channelPlans: { hasLegacy: boolean; - }; - pairingAllowFrom: { - hasLegacyTelegram: boolean; - copyPlans: FileCopyPlan[]; + plans: ChannelLegacyStateMigrationPlan[]; }; preview: string[]; }; -type FileCopyPlan = { - label: string; - sourcePath: string; - targetPath: string; -}; - type MigrationLogger = { info: (message: string) => void; warn: (message: string) => void; @@ -82,6 +70,20 @@ type MigrationLogger = { let autoMigrateChecked = false; let autoMigrateStateDirChecked = false; +type LegacySessionSurface = { + isLegacyGroupSessionKey?: (key: string) => boolean; + canonicalizeLegacySessionKey?: (params: { + key: string; + agentId: string; + }) => string | null | undefined; +}; + +function getLegacySessionSurfaces(): LegacySessionSurface[] { + return getBundledChannelContractSurfaces().filter( + (surface): surface is LegacySessionSurface => Boolean(surface) && typeof surface === "object", + ); +} + function isSurfaceGroupKey(key: string): boolean { return key.includes(":group:") || key.includes(":channel:"); } @@ -91,29 +93,20 @@ function isLegacyGroupKey(key: string): boolean { if (!trimmed) { return false; } - if (trimmed.startsWith("group:")) { - return true; - } - const lower = trimmed.toLowerCase(); - if (!lower.includes("@g.us")) { - return false; - } - // Legacy WhatsApp group keys: bare JID or "whatsapp:" without explicit ":group:" kind. - if (!trimmed.includes(":")) { - return true; - } - if (lower.startsWith("whatsapp:") && !trimmed.includes(":group:")) { - return true; + for (const surface of getLegacySessionSurfaces()) { + if (surface.isLegacyGroupSessionKey?.(trimmed)) { + return true; + } } return false; } -function buildFileCopyPreview(plan: FileCopyPlan): string { +function buildLegacyMigrationPreview(plan: ChannelLegacyStateMigrationPlan): string { return `- ${plan.label}: ${plan.sourcePath} → ${plan.targetPath}`; } -async function runFileCopyPlans( - plans: FileCopyPlan[], +async function runLegacyMigrationPlans( + plans: ChannelLegacyStateMigrationPlan[], ): Promise<{ changes: string[]; warnings: string[] }> { const changes: string[] = []; const warnings: string[] = []; @@ -123,8 +116,13 @@ async function runFileCopyPlans( } try { ensureDir(path.dirname(plan.targetPath)); - fs.copyFileSync(plan.sourcePath, plan.targetPath); - changes.push(`Copied ${plan.label} → ${plan.targetPath}`); + if (plan.kind === "move") { + fs.renameSync(plan.sourcePath, plan.targetPath); + changes.push(`Moved ${plan.label} → ${plan.targetPath}`); + } else { + fs.copyFileSync(plan.sourcePath, plan.targetPath); + changes.push(`Copied ${plan.label} → ${plan.targetPath}`); + } } catch (err) { warnings.push(`Failed migrating ${plan.label} (${plan.sourcePath}): ${String(err)}`); } @@ -208,22 +206,13 @@ function canonicalizeSessionKeyForAgent(params: { const rest = raw.slice("subagent:".length); return `agent:${agentId}:subagent:${rest}`.toLowerCase(); } - if (raw.startsWith("group:")) { - const id = raw.slice("group:".length).trim(); - if (!id) { - return raw; - } - const channel = id.toLowerCase().includes("@g.us") ? "whatsapp" : "unknown"; - return `agent:${agentId}:${channel}:group:${id}`.toLowerCase(); - } - if (!raw.includes(":") && raw.toLowerCase().includes("@g.us")) { - return `agent:${agentId}:whatsapp:group:${raw}`.toLowerCase(); - } - if (raw.toLowerCase().startsWith("whatsapp:") && raw.toLowerCase().includes("@g.us")) { - const remainder = raw.slice("whatsapp:".length).trim(); - const cleaned = remainder.replace(/^group:/i, "").trim(); - if (cleaned && !isSurfaceGroupKey(raw)) { - return `agent:${agentId}:whatsapp:group:${cleaned}`.toLowerCase(); + for (const surface of getLegacySessionSurfaces()) { + const canonicalized = surface.canonicalizeLegacySessionKey?.({ + key: raw, + agentId, + }); + if (typeof canonicalized === "string" && canonicalized.trim()) { + return canonicalized.trim().toLowerCase(); } } if (isSurfaceGroupKey(raw)) { @@ -652,6 +641,27 @@ export async function autoMigrateLegacyStateDir(params: { return { migrated: changes.length > 0, skipped: false, changes, warnings }; } +async function collectChannelLegacyStateMigrationPlans(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + stateDir: string; + oauthDir: string; +}): Promise { + const plans: ChannelLegacyStateMigrationPlan[] = []; + for (const plugin of listBundledChannelPlugins()) { + const detected = await plugin.lifecycle?.detectLegacyStateMigrations?.({ + cfg: params.cfg, + env: params.env, + stateDir: params.stateDir, + oauthDir: params.oauthDir, + }); + if (detected?.length) { + plans.push(...detected); + } + } + return plans; +} + export async function detectLegacyStateMigrations(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -694,30 +704,12 @@ export async function detectLegacyStateMigrations(params: { const legacyAgentDir = path.join(stateDir, "agent"); const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent"); const hasLegacyAgentDir = existsDir(legacyAgentDir); - - const targetWhatsAppAuthDir = path.join(oauthDir, "whatsapp", DEFAULT_ACCOUNT_ID); - const hasLegacyWhatsAppAuth = - fileExists(path.join(oauthDir, "creds.json")) && - !fileExists(path.join(targetWhatsAppAuthDir, "creds.json")); - const legacyTelegramAllowFromPath = resolveChannelAllowFromPath("telegram", env); - const targetTelegramAccountId = resolveDefaultTelegramAccountId(params.cfg); - const targetTelegramAllowFromPath = resolveChannelAllowFromPath( - "telegram", + const channelPlans = await collectChannelLegacyStateMigrationPlans({ + cfg: params.cfg, env, - targetTelegramAccountId, - ); - const telegramPairingAllowFromPlans = fileExists(legacyTelegramAllowFromPath) - ? [targetTelegramAllowFromPath] - .filter((targetPath) => !fileExists(targetPath)) - .map( - (targetPath): FileCopyPlan => ({ - label: "Telegram pairing allowFrom", - sourcePath: legacyTelegramAllowFromPath, - targetPath, - }), - ) - : []; - const hasLegacyTelegramAllowFrom = telegramPairingAllowFromPlans.length > 0; + stateDir, + oauthDir, + }); const preview: string[] = []; if (hasLegacySessions) { @@ -729,11 +721,8 @@ export async function detectLegacyStateMigrations(params: { if (hasLegacyAgentDir) { preview.push(`- Agent dir: ${legacyAgentDir} → ${targetAgentDir}`); } - if (hasLegacyWhatsAppAuth) { - preview.push(`- WhatsApp auth: ${oauthDir} → ${targetWhatsAppAuthDir} (keep oauth.json)`); - } - if (hasLegacyTelegramAllowFrom) { - preview.push(...telegramPairingAllowFromPlans.map(buildFileCopyPreview)); + if (channelPlans.length > 0) { + preview.push(...channelPlans.map(buildLegacyMigrationPreview)); } return { @@ -755,14 +744,9 @@ export async function detectLegacyStateMigrations(params: { targetDir: targetAgentDir, hasLegacy: hasLegacyAgentDir, }, - whatsappAuth: { - legacyDir: oauthDir, - targetDir: targetWhatsAppAuthDir, - hasLegacy: hasLegacyWhatsAppAuth, - }, - pairingAllowFrom: { - hasLegacyTelegram: hasLegacyTelegramAllowFrom, - copyPlans: telegramPairingAllowFromPlans, + channelPlans: { + hasLegacy: channelPlans.length > 0, + plans: channelPlans, }, preview, }; @@ -942,53 +926,15 @@ export async function migrateLegacyAgentDir( return { changes, warnings }; } -async function migrateLegacyWhatsAppAuth( +async function migrateChannelLegacyStatePlans( detected: LegacyStateDetection, ): Promise<{ changes: string[]; warnings: string[] }> { const changes: string[] = []; const warnings: string[] = []; - if (!detected.whatsappAuth.hasLegacy) { + if (!detected.channelPlans.hasLegacy) { return { changes, warnings }; } - - ensureDir(detected.whatsappAuth.targetDir); - - const entries = safeReadDir(detected.whatsappAuth.legacyDir); - for (const entry of entries) { - if (!entry.isFile()) { - continue; - } - if (entry.name === "oauth.json") { - continue; - } - if (!isLegacyWhatsAppAuthFile(entry.name)) { - continue; - } - const from = path.join(detected.whatsappAuth.legacyDir, entry.name); - const to = path.join(detected.whatsappAuth.targetDir, entry.name); - if (fileExists(to)) { - continue; - } - try { - fs.renameSync(from, to); - changes.push(`Moved WhatsApp auth ${entry.name} → whatsapp/default`); - } catch (err) { - warnings.push(`Failed moving ${from}: ${String(err)}`); - } - } - - return { changes, warnings }; -} - -async function migrateLegacyTelegramPairingAllowFrom( - detected: LegacyStateDetection, -): Promise<{ changes: string[]; warnings: string[] }> { - const changes: string[] = []; - const warnings: string[] = []; - if (!detected.pairingAllowFrom.hasLegacyTelegram) { - return { changes, warnings }; - } - return await runFileCopyPlans(detected.pairingAllowFrom.copyPlans); + return await runLegacyMigrationPlans(detected.channelPlans.plans); } export async function runLegacyStateMigrations(params: { @@ -999,21 +945,10 @@ export async function runLegacyStateMigrations(params: { const detected = params.detected; const sessions = await migrateLegacySessions(detected, now); const agentDir = await migrateLegacyAgentDir(detected, now); - const whatsappAuth = await migrateLegacyWhatsAppAuth(detected); - const telegramPairingAllowFrom = await migrateLegacyTelegramPairingAllowFrom(detected); + const channelPlans = await migrateChannelLegacyStatePlans(detected); return { - changes: [ - ...sessions.changes, - ...agentDir.changes, - ...whatsappAuth.changes, - ...telegramPairingAllowFrom.changes, - ], - warnings: [ - ...sessions.warnings, - ...agentDir.warnings, - ...whatsappAuth.warnings, - ...telegramPairingAllowFrom.warnings, - ], + changes: [...sessions.changes, ...agentDir.changes, ...channelPlans.changes], + warnings: [...sessions.warnings, ...agentDir.warnings, ...channelPlans.warnings], }; } diff --git a/src/library.ts b/src/library.ts index bbade80b080..515aec74556 100644 --- a/src/library.ts +++ b/src/library.ts @@ -19,7 +19,7 @@ import type { runCommandWithTimeout as runCommandWithTimeoutRuntime, runExec as runExecRuntime, } from "./process/exec.js"; -import { assertWebChannel, normalizeE164, toWhatsappJid } from "./utils.js"; +import { normalizeE164 } from "./utils.js"; type GetReplyFromConfig = typeof getReplyFromConfigRuntime; type PromptYesNo = typeof promptYesNoRuntime; @@ -74,7 +74,6 @@ export const monitorWebChannel: MonitorWebChannel = async (...args) => (await loadWebChannelRuntime()).monitorWebChannel(...args); export { - assertWebChannel, applyTemplate, createDefaultDeps, deriveSessionKey, @@ -88,6 +87,5 @@ export { resolveSessionKey, resolveStorePath, saveSessionStore, - toWhatsappJid, waitForever, }; diff --git a/src/logging/subsystem.ts b/src/logging/subsystem.ts index 0b74b645859..00bdc9cc4e1 100644 --- a/src/logging/subsystem.ts +++ b/src/logging/subsystem.ts @@ -3,6 +3,7 @@ import type { Logger as TsLogger } from "tslog"; import { isVerbose } from "../global-state.js"; import { defaultRuntime, type OutputRuntimeEnv, type RuntimeEnv } from "../runtime.js"; import { clearActiveProgressLine } from "../terminal/progress-line.js"; +import { normalizeMessageChannel } from "../utils/message-channel.js"; import { formatConsoleTimestamp, getConsoleSettings, @@ -97,17 +98,10 @@ const SUBSYSTEM_COLOR_OVERRIDES: Record([ - "telegram", - "whatsapp", - "discord", - "irc", - "googlechat", - "slack", - "signal", - "imessage", -]); + +function isChannelSubsystemPrefix(value: string): boolean { + return normalizeMessageChannel(value) === value; +} function pickSubsystemColor(color: ChalkInstance, subsystem: string): ChalkInstance { const override = SUBSYSTEM_COLOR_OVERRIDES[subsystem]; @@ -135,7 +129,7 @@ function formatSubsystemForConsole(subsystem: string): string { if (parts.length === 0) { return original; } - if (CHANNEL_SUBSYSTEM_PREFIXES.has(parts[0])) { + if (isChannelSubsystemPrefix(parts[0])) { return parts[0]; } if (parts.length > SUBSYSTEM_MAX_SEGMENTS) { diff --git a/src/markdown/whatsapp.ts b/src/markdown/whatsapp.ts deleted file mode 100644 index 9532bc8f7c2..00000000000 --- a/src/markdown/whatsapp.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { escapeRegExp } from "../utils.js"; -/** - * Convert standard Markdown formatting to WhatsApp-compatible markup. - * - * WhatsApp uses its own formatting syntax: - * bold: *text* - * italic: _text_ - * strikethrough: ~text~ - * monospace: ```text``` - * - * Standard Markdown uses: - * bold: **text** or __text__ - * italic: *text* or _text_ - * strikethrough: ~~text~~ - * code: `text` (inline) or ```text``` (block) - * - * The conversion preserves fenced code blocks and inline code, - * then converts bold and strikethrough markers. - */ - -/** Placeholder tokens used during conversion to protect code spans. */ -const FENCE_PLACEHOLDER = "\x00FENCE"; -const INLINE_CODE_PLACEHOLDER = "\x00CODE"; - -/** - * Convert standard Markdown bold/italic/strikethrough to WhatsApp formatting. - * - * Order of operations matters: - * 1. Protect fenced code blocks (```...```) — already WhatsApp-compatible - * 2. Protect inline code (`...`) — leave as-is - * 3. Convert **bold** → *bold* and __bold__ → *bold* - * 4. Convert ~~strike~~ → ~strike~ - * 5. Restore protected spans - * - * Italic *text* and _text_ are left alone since WhatsApp uses _text_ for italic - * and single * is already WhatsApp bold — no conversion needed for single markers. - */ -export function markdownToWhatsApp(text: string): string { - if (!text) { - return text; - } - - // 1. Extract and protect fenced code blocks - const fences: string[] = []; - let result = text.replace(/```[\s\S]*?```/g, (match) => { - fences.push(match); - return `${FENCE_PLACEHOLDER}${fences.length - 1}`; - }); - - // 2. Extract and protect inline code - const inlineCodes: string[] = []; - result = result.replace(/`[^`\n]+`/g, (match) => { - inlineCodes.push(match); - return `${INLINE_CODE_PLACEHOLDER}${inlineCodes.length - 1}`; - }); - - // 3. Convert **bold** → *bold* and __bold__ → *bold* - result = result.replace(/\*\*(.+?)\*\*/g, "*$1*"); - result = result.replace(/__(.+?)__/g, "*$1*"); - - // 4. Convert ~~strikethrough~~ → ~strikethrough~ - result = result.replace(/~~(.+?)~~/g, "~$1~"); - - // 5. Restore inline code - result = result.replace( - new RegExp(`${escapeRegExp(INLINE_CODE_PLACEHOLDER)}(\\d+)`, "g"), - (_, idx) => inlineCodes[Number(idx)] ?? "", - ); - - // 6. Restore fenced code blocks - result = result.replace( - new RegExp(`${escapeRegExp(FENCE_PLACEHOLDER)}(\\d+)`, "g"), - (_, idx) => fences[Number(idx)] ?? "", - ); - - return result; -} diff --git a/src/media-understanding/attachments.cache.ts b/src/media-understanding/attachments.cache.ts index ab3761a9932..365b60bda00 100644 --- a/src/media-understanding/attachments.cache.ts +++ b/src/media-understanding/attachments.cache.ts @@ -4,11 +4,7 @@ import path from "node:path"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { isAbortError } from "../infra/unhandled-rejections.js"; import { fetchRemoteMedia, MediaFetchError } from "../media/fetch.js"; -import { - DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, - isInboundPathAllowed, - mergeInboundPathRoots, -} from "../media/inbound-path-policy.js"; +import { isInboundPathAllowed, mergeInboundPathRoots } from "../media/inbound-path-policy.js"; import { getDefaultMediaLocalRoots } from "../media/local-roots.js"; import { detectMime } from "../media/mime.js"; import { buildRandomTempFilePath } from "../plugin-sdk/temp-path.js"; @@ -48,10 +44,7 @@ type AttachmentCacheEntry = { let defaultLocalPathRoots: readonly string[] | undefined; function getDefaultLocalPathRoots(): readonly string[] { - defaultLocalPathRoots ??= mergeInboundPathRoots( - getDefaultMediaLocalRoots(), - DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, - ); + defaultLocalPathRoots ??= mergeInboundPathRoots(getDefaultMediaLocalRoots()); return defaultLocalPathRoots; } diff --git a/src/media-understanding/runner.ts b/src/media-understanding/runner.ts index 03050fed066..0d168d17d97 100644 --- a/src/media-understanding/runner.ts +++ b/src/media-understanding/runner.ts @@ -20,10 +20,8 @@ import type { MediaUnderstandingModelConfig, } from "../config/types.tools.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; -import { - mergeInboundPathRoots, - resolveIMessageAttachmentRoots, -} from "../media/inbound-path-policy.js"; +import { resolveChannelInboundAttachmentRoots } from "../media/channel-inbound-roots.js"; +import { mergeInboundPathRoots } from "../media/inbound-path-policy.js"; import { getDefaultMediaLocalRoots } from "../media/local-roots.js"; import { runExec } from "../process/exec.js"; import { MediaAttachmentCache, selectAttachments } from "./attachments.js"; @@ -178,10 +176,7 @@ export function resolveMediaAttachmentLocalRoots(params: { }): readonly string[] { return mergeInboundPathRoots( getDefaultMediaLocalRoots(), - resolveIMessageAttachmentRoots({ - cfg: params.cfg, - accountId: params.ctx.AccountId, - }), + resolveChannelInboundAttachmentRoots(params), ); } diff --git a/src/media/channel-inbound-roots.ts b/src/media/channel-inbound-roots.ts new file mode 100644 index 00000000000..1520c59ceb0 --- /dev/null +++ b/src/media/channel-inbound-roots.ts @@ -0,0 +1,53 @@ +import type { MsgContext } from "../auto-reply/templating.js"; +import { getBundledChannelContractSurfaceEntries } from "../channels/plugins/contract-surfaces.js"; +import type { OpenClawConfig } from "../config/config.js"; + +type ChannelInboundMediaRootsSurface = { + resolveInboundAttachmentRoots?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + }) => string[]; + resolveRemoteInboundAttachmentRoots?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + }) => string[]; +}; + +function normalizeChannelId(value?: string | null): string | undefined { + const normalized = value?.trim().toLowerCase(); + return normalized || undefined; +} + +function findChannelMediaSurface( + channelId?: string | null, +): ChannelInboundMediaRootsSurface | undefined { + const normalized = normalizeChannelId(channelId); + if (!normalized) { + return undefined; + } + return getBundledChannelContractSurfaceEntries().find( + (entry) => normalizeChannelId(entry.pluginId) === normalized, + )?.surface as ChannelInboundMediaRootsSurface | undefined; +} + +export function resolveChannelInboundAttachmentRoots(params: { + cfg: OpenClawConfig; + ctx: MsgContext; +}): readonly string[] | undefined { + const surface = findChannelMediaSurface(params.ctx.Surface ?? params.ctx.Provider); + return surface?.resolveInboundAttachmentRoots?.({ + cfg: params.cfg, + accountId: params.ctx.AccountId, + }); +} + +export function resolveChannelRemoteInboundAttachmentRoots(params: { + cfg: OpenClawConfig; + ctx: MsgContext; +}): readonly string[] | undefined { + const surface = findChannelMediaSurface(params.ctx.Surface ?? params.ctx.Provider); + return surface?.resolveRemoteInboundAttachmentRoots?.({ + cfg: params.cfg, + accountId: params.ctx.AccountId, + }); +} diff --git a/src/media/inbound-path-policy.ts b/src/media/inbound-path-policy.ts index 8c77fbe5c03..33d9bb2e605 100644 --- a/src/media/inbound-path-policy.ts +++ b/src/media/inbound-path-policy.ts @@ -1,14 +1,9 @@ import path from "node:path"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { normalizeAccountId } from "../routing/session-key.js"; const WILDCARD_SEGMENT = "*"; const WINDOWS_DRIVE_ABS_RE = /^[A-Za-z]:\//; const WINDOWS_DRIVE_ROOT_RE = /^[A-Za-z]:$/; -export const DEFAULT_IMESSAGE_ATTACHMENT_ROOTS = ["/Users/*/Library/Messages/Attachments"] as const; - function normalizePosixAbsolutePath(value: string): string | undefined { const trimmed = value.trim(); if (!trimmed || trimmed.includes("\0")) { @@ -116,37 +111,3 @@ export function isInboundPathAllowed(params: { } return effectiveRoots.some((rootPattern) => matchesRootPattern({ candidatePath, rootPattern })); } - -function resolveIMessageAccountConfig(params: { cfg: OpenClawConfig; accountId?: string | null }) { - const accountId = normalizeAccountId(params.accountId); - if (!params.accountId?.trim()) { - return undefined; - } - return resolveAccountEntry(params.cfg.channels?.imessage?.accounts, accountId); -} - -export function resolveIMessageAttachmentRoots(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): string[] { - const accountConfig = resolveIMessageAccountConfig(params); - return mergeInboundPathRoots( - accountConfig?.attachmentRoots, - params.cfg.channels?.imessage?.attachmentRoots, - DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, - ); -} - -export function resolveIMessageRemoteAttachmentRoots(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): string[] { - const accountConfig = resolveIMessageAccountConfig(params); - return mergeInboundPathRoots( - accountConfig?.remoteAttachmentRoots, - params.cfg.channels?.imessage?.remoteAttachmentRoots, - accountConfig?.attachmentRoots, - params.cfg.channels?.imessage?.attachmentRoots, - DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, - ); -} diff --git a/src/plugin-sdk/agent-media-payload.ts b/src/plugin-sdk/agent-media-payload.ts index 5fa1fb767f5..09f7f1fb4ba 100644 --- a/src/plugin-sdk/agent-media-payload.ts +++ b/src/plugin-sdk/agent-media-payload.ts @@ -1,3 +1,5 @@ +export { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; + export type AgentMediaPayload = { MediaPath?: string; MediaType?: string; diff --git a/src/plugin-sdk/channel-contract.ts b/src/plugin-sdk/channel-contract.ts index 0c117ee83de..5c6c76df24d 100644 --- a/src/plugin-sdk/channel-contract.ts +++ b/src/plugin-sdk/channel-contract.ts @@ -7,6 +7,9 @@ export type { ChannelApprovalAdapter, ChannelApprovalCapability, ChannelCommandConversationContext, + ChannelDirectoryEntry, + ChannelResolveKind, + ChannelResolveResult, ChannelGroupContext, ChannelMessageActionAdapter, ChannelMessageActionContext, @@ -18,7 +21,9 @@ export type { ChannelStatusIssue, ChannelThreadingContext, ChannelThreadingToolContext, + ChannelToolSend, } from "../channels/plugins/types.js"; +export type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; export type { ChannelDirectoryAdapter, @@ -26,4 +31,6 @@ export type { ChannelDoctorConfigMutation, ChannelDoctorEmptyAllowlistAccountContext, ChannelDoctorSequenceResult, + ChannelGatewayContext, + ChannelOutboundAdapter, } from "../channels/plugins/types.adapters.js"; diff --git a/src/plugin-sdk/channel-pairing.ts b/src/plugin-sdk/channel-pairing.ts index 301eaddc863..0248090bfd1 100644 --- a/src/plugin-sdk/channel-pairing.ts +++ b/src/plugin-sdk/channel-pairing.ts @@ -4,7 +4,11 @@ export { createPairingPrefixStripper, createTextPairingAdapter, } from "../channels/plugins/pairing-adapters.js"; -export { readChannelAllowFromStoreSync } from "../pairing/pairing-store.js"; +export { + readChannelAllowFromStore, + readChannelAllowFromStoreSync, +} from "../pairing/pairing-store.js"; +export { resolveChannelAllowFromPath } from "../pairing/pairing-store.js"; import { issuePairingChallenge } from "../pairing/pairing-challenge.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import { createScopedPairingAccess } from "./pairing-access.js"; diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index ab07ebc0620..4c986768334 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -1,7 +1,9 @@ import { createAllowlistProviderRestrictSendersWarningCollector } from "../channels/plugins/group-policy-warnings.js"; import type { ChannelSecurityAdapter } from "../channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../config/config.js"; +import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; import type { GroupPolicy } from "../config/types.base.js"; +import { sanitizeForLog } from "../terminal/ansi.js"; import { createScopedDmSecurityResolver } from "./channel-config-helpers.js"; /** Shared policy warnings and DM/group policy helpers for channel plugins. */ export type { @@ -46,6 +48,79 @@ export { } from "../security/dm-policy-shared.js"; export { createAllowlistProviderRestrictSendersWarningCollector }; +export type ChannelMutableAllowlistCandidate = { + pathLabel: string; + list: unknown; +}; + +type ChannelMutableAllowlistHit = { + path: string; + entry: string; + dangerousFlagPath: string; +}; + +function collectMutableAllowlistWarningLines( + hits: ChannelMutableAllowlistHit[], + channel: string, +): string[] { + if (hits.length === 0) { + return []; + } + const exampleLines = hits + .slice(0, 8) + .map((hit) => `- ${sanitizeForLog(hit.path)}: ${sanitizeForLog(hit.entry)}`); + const remaining = + hits.length > 8 ? `- +${hits.length - 8} more mutable allowlist entries.` : null; + const flagPaths = Array.from(new Set(hits.map((hit) => hit.dangerousFlagPath))); + const flagHint = + flagPaths.length === 1 + ? sanitizeForLog(flagPaths[0] ?? "") + : `${sanitizeForLog(flagPaths[0] ?? "")} (and ${flagPaths.length - 1} other scope flags)`; + return [ + `- Found ${hits.length} mutable allowlist ${hits.length === 1 ? "entry" : "entries"} across ${channel} while name matching is disabled by default.`, + ...exampleLines, + ...(remaining ? [remaining] : []), + `- Option A (break-glass): enable ${flagHint}=true to keep name/email/nick matching.`, + "- Option B (recommended): resolve names/emails/nicks to stable sender IDs and rewrite the allowlist entries.", + ]; +} + +export function createDangerousNameMatchingMutableAllowlistWarningCollector(params: { + channel: string; + detector: (entry: string) => boolean; + collectLists: (scope: { + prefix: string; + account: Record; + dangerousFlagPath: string; + }) => ChannelMutableAllowlistCandidate[]; +}) { + return ({ cfg }: { cfg: OpenClawConfig }): string[] => { + const hits: ChannelMutableAllowlistHit[] = []; + for (const scope of collectProviderDangerousNameMatchingScopes(cfg, params.channel)) { + if (scope.dangerousNameMatchingEnabled) { + continue; + } + for (const candidate of params.collectLists(scope)) { + if (!Array.isArray(candidate.list)) { + continue; + } + for (const entry of candidate.list) { + const text = String(entry).trim(); + if (!text || text === "*" || !params.detector(text)) { + continue; + } + hits.push({ + path: candidate.pathLabel, + entry: text, + dangerousFlagPath: scope.dangerousFlagPath, + }); + } + } + } + return collectMutableAllowlistWarningLines(hits, params.channel); + }; +} + /** Compose the common DM policy resolver with restrict-senders group warnings. */ export function createRestrictSendersChannelSecurity< ResolvedAccount extends { accountId?: string | null }, diff --git a/src/plugin-sdk/channel-reply-pipeline.ts b/src/plugin-sdk/channel-reply-pipeline.ts index 600fe638217..3ffb37ee2cc 100644 --- a/src/plugin-sdk/channel-reply-pipeline.ts +++ b/src/plugin-sdk/channel-reply-pipeline.ts @@ -1,4 +1,7 @@ +import type { ReplyPayload } from "../auto-reply/types.js"; +import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; import { + createReplyPrefixContext, createReplyPrefixOptions, type ReplyPrefixContextBundle, type ReplyPrefixOptions, @@ -12,9 +15,11 @@ import { export type ReplyPrefixContext = ReplyPrefixContextBundle["prefixContext"]; export type { ReplyPrefixContextBundle, ReplyPrefixOptions }; export type { CreateTypingCallbacksParams, TypingCallbacks }; +export { createReplyPrefixContext, createReplyPrefixOptions, createTypingCallbacks }; export type ChannelReplyPipeline = ReplyPrefixOptions & { typingCallbacks?: TypingCallbacks; + transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null; }; export function createChannelReplyPipeline(params: { @@ -25,6 +30,18 @@ export function createChannelReplyPipeline(params: { typing?: CreateTypingCallbacksParams; typingCallbacks?: TypingCallbacks; }): ChannelReplyPipeline { + const channelId = params.channel + ? (normalizeChannelId(params.channel) ?? params.channel) + : undefined; + const plugin = channelId ? getChannelPlugin(channelId) : undefined; + const transformReplyPayload = plugin?.messaging?.transformReplyPayload + ? (payload: ReplyPayload) => + plugin.messaging?.transformReplyPayload?.({ + payload, + cfg: params.cfg, + accountId: params.accountId, + }) ?? payload + : undefined; return { ...createReplyPrefixOptions({ cfg: params.cfg, @@ -32,6 +49,7 @@ export function createChannelReplyPipeline(params: { channel: params.channel, accountId: params.accountId, }), + ...(transformReplyPayload ? { transformReplyPayload } : {}), ...(params.typingCallbacks ? { typingCallbacks: params.typingCallbacks } : params.typing diff --git a/src/plugin-sdk/channel-status.ts b/src/plugin-sdk/channel-status.ts index 59ac40768ff..12a22755944 100644 --- a/src/plugin-sdk/channel-status.ts +++ b/src/plugin-sdk/channel-status.ts @@ -6,7 +6,10 @@ export { resolveConfiguredFromRequiredCredentialStatuses, } from "../channels/account-snapshot-fields.js"; export { + buildBaseChannelStatusSummary, + createDefaultChannelRuntimeState, buildProbeChannelStatusSummary, buildComputedAccountStatusSnapshot, buildTokenChannelStatusSummary, + collectStatusIssuesFromLastError, } from "./status-helpers.js"; diff --git a/src/plugin-sdk/channel-targets.ts b/src/plugin-sdk/channel-targets.ts index c6b15132605..e36a925ff7a 100644 --- a/src/plugin-sdk/channel-targets.ts +++ b/src/plugin-sdk/channel-targets.ts @@ -37,10 +37,6 @@ export { type ParsedChatTarget, type ServicePrefix, } from "../channels/plugins/chat-target-prefixes.js"; -export { - looksLikeSignalTargetId, - normalizeSignalMessagingTarget, -} from "../channels/plugins/normalize/signal.js"; export type { ChannelId } from "../channels/plugins/types.js"; export { normalizeChannelId } from "../channels/plugins/registry.js"; export { diff --git a/src/plugin-sdk/config-runtime.ts b/src/plugin-sdk/config-runtime.ts index 891f880c729..bc090bca68c 100644 --- a/src/plugin-sdk/config-runtime.ts +++ b/src/plugin-sdk/config-runtime.ts @@ -13,6 +13,10 @@ export { export { logConfigUpdated } from "../config/logging.js"; export { updateConfig } from "../commands/models/shared.js"; export { resolveChannelModelOverride } from "../channels/model-overrides.js"; +export { + evaluateSupplementalContextVisibility, + filterSupplementalContextItems, +} from "../security/context-visibility.js"; export { resolveChannelContextVisibilityMode, resolveDefaultContextVisibility, @@ -39,18 +43,7 @@ export { TELEGRAM_COMMAND_NAME_PATTERN, normalizeTelegramCommandName, resolveTelegramCustomCommands, -} from "../config/telegram-custom-commands.js"; -export { - formatSlackStreamingBooleanMigrationMessage, - formatSlackStreamModeMigrationMessage, - mapStreamingModeToSlackLegacyDraftStreamMode, - resolveDiscordPreviewStreamMode, - resolveSlackNativeStreaming, - resolveSlackStreamingMode, - resolveTelegramPreviewStreamMode, - type SlackLegacyDraftStreamMode, - type StreamingMode, -} from "../config/discord-preview-streaming.js"; +} from "./telegram-command-config.js"; export { resolveActiveTalkProviderConfig } from "../config/talk.js"; export { resolveAgentMaxConcurrent } from "../config/agent-limits.js"; export { loadCronStore, resolveCronStorePath, saveCronStore } from "../cron/store.js"; @@ -62,6 +55,7 @@ export { resolveRequiredConfiguredSecretRefInputString, } from "../gateway/resolve-configured-secret-input-string.js"; export type { + BlockStreamingCoalesceConfig, DiscordAccountConfig, DiscordActionConfig, DiscordAutoPresenceConfig, @@ -71,9 +65,13 @@ export type { DiscordGuildEntry, DiscordIntentsConfig, DiscordSlashCommandConfig, + DmConfig, DmPolicy, ContextVisibilityMode, GroupPolicy, + GroupToolPolicyBySenderConfig, + GroupToolPolicyConfig, + MarkdownConfig, MarkdownTableMode, OpenClawConfig, ReplyToMode, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 16b944916f2..937a43ecd7d 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -77,6 +77,23 @@ export type { } from "./plugin-entry.js"; export type { OpenClawPluginToolContext, OpenClawPluginToolFactory } from "../plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; +export type { OutboundIdentity } from "../infra/outbound/identity.js"; +export type { HistoryEntry } from "../auto-reply/reply/history.js"; +export type { ReplyPayload } from "../auto-reply/types.js"; +export type { AllowlistMatch } from "../channels/allowlist-match.js"; +export type { + BaseProbeResult, + ChannelAccountSnapshot, + ChannelGroupContext, + ChannelMessageActionName, + ChannelMeta, + ChannelSetupInput, +} from "../channels/plugins/types.js"; +export type { ChatType } from "../channels/chat-type.js"; +export type { NormalizedLocation } from "../channels/location.js"; +export type { ChannelDirectoryEntry } from "../channels/plugins/types.core.js"; +export type { ChannelOutboundAdapter } from "../channels/plugins/types.adapters.js"; +export type { PollInput } from "../polls.js"; export { isSecretRef } from "../config/types.secrets.js"; export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js"; export type { @@ -109,7 +126,8 @@ export type { } from "../infra/provider-usage.types.js"; export type { ChannelMessageActionContext } from "../channels/plugins/types.js"; export type { ChannelConfigUiHint, ChannelPlugin } from "../channels/plugins/types.plugin.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; +export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; +export type { WizardPrompter } from "../wizard/prompts.js"; export { definePluginEntry } from "./plugin-entry.js"; export { buildPluginConfigSchema, emptyPluginConfigSchema } from "../plugins/config-schema.js"; @@ -152,6 +170,19 @@ export type { GatewayBindUrlResult } from "../shared/gateway-bind-url.js"; export { resolveGatewayPort } from "../config/paths.js"; export { createSubsystemLogger } from "../logging/subsystem.js"; export { normalizeAtHashSlug, normalizeHyphenSlug } from "../shared/string-normalization.js"; +export { createActionGate } from "../agents/tools/common.js"; +export { + jsonResult, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringParam, +} from "../agents/tools/common.js"; +export { parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; +export { isTrustedProxyAddress, resolveClientIp } from "../gateway/net.js"; +export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js"; +export { ensureConfiguredAcpBindingReady } from "../acp/persistent-bindings.lifecycle.js"; +export { resolveConfiguredAcpBindingRecord } from "../acp/persistent-bindings.resolve.js"; export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; export type { diff --git a/src/plugin-sdk/google-model-id.ts b/src/plugin-sdk/google-model-id.ts index 076fa1c9059..293a26184d6 100644 --- a/src/plugin-sdk/google-model-id.ts +++ b/src/plugin-sdk/google-model-id.ts @@ -1,27 +1,4 @@ -const ANTIGRAVITY_BARE_PRO_IDS = new Set(["gemini-3-pro", "gemini-3.1-pro", "gemini-3-1-pro"]); - -export function normalizeGoogleModelId(id: string): string { - if (id === "gemini-3-pro") { - return "gemini-3-pro-preview"; - } - if (id === "gemini-3-flash") { - return "gemini-3-flash-preview"; - } - if (id === "gemini-3.1-pro") { - return "gemini-3.1-pro-preview"; - } - if (id === "gemini-3.1-flash-lite") { - return "gemini-3.1-flash-lite-preview"; - } - if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") { - return "gemini-3-flash-preview"; - } - return id; -} - -export function normalizeAntigravityModelId(id: string): string { - if (ANTIGRAVITY_BARE_PRO_IDS.has(id)) { - return `${id}-low`; - } - return id; -} +export { + normalizeAntigravityPreviewModelId as normalizeAntigravityModelId, + normalizeGooglePreviewModelId as normalizeGoogleModelId, +} from "./provider-model-shared.js"; diff --git a/src/plugin-sdk/matrix-runtime-heavy.ts b/src/plugin-sdk/matrix-runtime-heavy.ts index 65ac24c8578..98259b70eeb 100644 --- a/src/plugin-sdk/matrix-runtime-heavy.ts +++ b/src/plugin-sdk/matrix-runtime-heavy.ts @@ -1,19 +1,57 @@ -// Matrix runtime helpers that are needed internally by the bundled extension -// but are too heavy for the light external runtime-api surface. +import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; -export { ensureConfiguredAcpBindingReady } from "../acp/persistent-bindings.lifecycle.js"; -export { resolveConfiguredAcpBindingRecord } from "../acp/persistent-bindings.resolve.js"; -export { - autoPrepareLegacyMatrixCrypto, - detectLegacyMatrixCrypto, -} from "../infra/matrix-legacy-crypto.js"; -export { - autoMigrateLegacyMatrixState, - detectLegacyMatrixState, -} from "../infra/matrix-legacy-state.js"; -export { - hasActionableMatrixMigration, - hasPendingMatrixMigration, - maybeCreateMatrixMigrationSnapshot, -} from "../infra/matrix-migration-snapshot.js"; -export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js"; +type MatrixRuntimeHeavyModule = { + autoPrepareLegacyMatrixCrypto: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["autoPrepareLegacyMatrixCrypto"]; + detectLegacyMatrixCrypto: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["detectLegacyMatrixCrypto"]; + autoMigrateLegacyMatrixState: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["autoMigrateLegacyMatrixState"]; + detectLegacyMatrixState: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["detectLegacyMatrixState"]; + hasActionableMatrixMigration: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["hasActionableMatrixMigration"]; + hasPendingMatrixMigration: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["hasPendingMatrixMigration"]; + maybeCreateMatrixMigrationSnapshot: (typeof import("../../extensions/matrix/src/runtime-heavy-api.js"))["maybeCreateMatrixMigrationSnapshot"]; +}; + +function loadFacadeModule(): MatrixRuntimeHeavyModule { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "matrix", + artifactBasename: "runtime-heavy-api.js", + }); +} + +export const autoPrepareLegacyMatrixCrypto: MatrixRuntimeHeavyModule["autoPrepareLegacyMatrixCrypto"] = + ((...args) => + loadFacadeModule().autoPrepareLegacyMatrixCrypto( + ...args, + )) as MatrixRuntimeHeavyModule["autoPrepareLegacyMatrixCrypto"]; +export const detectLegacyMatrixCrypto: MatrixRuntimeHeavyModule["detectLegacyMatrixCrypto"] = (( + ...args +) => + loadFacadeModule().detectLegacyMatrixCrypto( + ...args, + )) as MatrixRuntimeHeavyModule["detectLegacyMatrixCrypto"]; +export const autoMigrateLegacyMatrixState: MatrixRuntimeHeavyModule["autoMigrateLegacyMatrixState"] = + ((...args) => + loadFacadeModule().autoMigrateLegacyMatrixState( + ...args, + )) as MatrixRuntimeHeavyModule["autoMigrateLegacyMatrixState"]; +export const detectLegacyMatrixState: MatrixRuntimeHeavyModule["detectLegacyMatrixState"] = (( + ...args +) => + loadFacadeModule().detectLegacyMatrixState( + ...args, + )) as MatrixRuntimeHeavyModule["detectLegacyMatrixState"]; +export const hasActionableMatrixMigration: MatrixRuntimeHeavyModule["hasActionableMatrixMigration"] = + ((...args) => + loadFacadeModule().hasActionableMatrixMigration( + ...args, + )) as MatrixRuntimeHeavyModule["hasActionableMatrixMigration"]; +export const hasPendingMatrixMigration: MatrixRuntimeHeavyModule["hasPendingMatrixMigration"] = (( + ...args +) => + loadFacadeModule().hasPendingMatrixMigration( + ...args, + )) as MatrixRuntimeHeavyModule["hasPendingMatrixMigration"]; +export const maybeCreateMatrixMigrationSnapshot: MatrixRuntimeHeavyModule["maybeCreateMatrixMigrationSnapshot"] = + ((...args) => + loadFacadeModule().maybeCreateMatrixMigrationSnapshot( + ...args, + )) as MatrixRuntimeHeavyModule["maybeCreateMatrixMigrationSnapshot"]; diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index 19029851971..908b39e3c5d 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -41,3 +41,66 @@ export { cloneFirstTemplateModel, matchesExactOrPrefix, } from "../plugins/provider-model-helpers.js"; + +export function getModelProviderHint(modelId: string): string | null { + const trimmed = modelId.trim().toLowerCase(); + const slashIndex = trimmed.indexOf("/"); + if (slashIndex <= 0) { + return null; + } + return trimmed.slice(0, slashIndex) || null; +} + +export function isProxyReasoningUnsupportedModelHint(modelId: string): boolean { + return getModelProviderHint(modelId) === "x-ai"; +} + +const ANTIGRAVITY_BARE_PRO_IDS = new Set(["gemini-3-pro", "gemini-3.1-pro", "gemini-3-1-pro"]); + +export function normalizeGooglePreviewModelId(id: string): string { + if (id === "gemini-3-pro") { + return "gemini-3-pro-preview"; + } + if (id === "gemini-3-flash") { + return "gemini-3-flash-preview"; + } + if (id === "gemini-3.1-pro") { + return "gemini-3.1-pro-preview"; + } + if (id === "gemini-3.1-flash-lite") { + return "gemini-3.1-flash-lite-preview"; + } + if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") { + return "gemini-3-flash-preview"; + } + return id; +} + +export function normalizeAntigravityPreviewModelId(id: string): string { + if (ANTIGRAVITY_BARE_PRO_IDS.has(id)) { + return `${id}-low`; + } + return id; +} + +export function normalizeNativeXaiModelId(id: string): string { + if (id === "grok-4-fast-reasoning") { + return "grok-4-fast"; + } + if (id === "grok-4-1-fast-reasoning") { + return "grok-4-1-fast"; + } + if (id === "grok-4.20-experimental-beta-0304-reasoning") { + return "grok-4.20-beta-latest-reasoning"; + } + if (id === "grok-4.20-experimental-beta-0304-non-reasoning") { + return "grok-4.20-beta-latest-non-reasoning"; + } + if (id === "grok-4.20-reasoning") { + return "grok-4.20-beta-latest-reasoning"; + } + if (id === "grok-4.20-non-reasoning") { + return "grok-4.20-beta-latest-non-reasoning"; + } + return id; +} diff --git a/src/plugin-sdk/reply-runtime.ts b/src/plugin-sdk/reply-runtime.ts index a820257e9d3..c6afedd6080 100644 --- a/src/plugin-sdk/reply-runtime.ts +++ b/src/plugin-sdk/reply-runtime.ts @@ -51,6 +51,7 @@ export type { } from "../auto-reply/reply/reply-dispatcher.js"; export { createReplyReferencePlanner } from "../auto-reply/reply/reply-reference.js"; export type { GetReplyOptions, ReplyPayload } from "../auto-reply/types.js"; +export type { BlockReplyContext } from "../auto-reply/types.js"; export type { FinalizedMsgContext, MsgContext } from "../auto-reply/templating.js"; export { generateConversationLabel } from "../auto-reply/reply/conversation-label-generator.js"; export type { ConversationLabelParams } from "../auto-reply/reply/conversation-label-generator.js"; diff --git a/src/plugin-sdk/retry-runtime.ts b/src/plugin-sdk/retry-runtime.ts index 768592e9c6f..872bfb850fe 100644 --- a/src/plugin-sdk/retry-runtime.ts +++ b/src/plugin-sdk/retry-runtime.ts @@ -9,7 +9,7 @@ export { } from "../infra/retry.js"; export { createRateLimitRetryRunner, - createTelegramRetryRunner, - TELEGRAM_RETRY_DEFAULTS, + createChannelApiRetryRunner as createTelegramRetryRunner, + CHANNEL_API_RETRY_DEFAULTS as TELEGRAM_RETRY_DEFAULTS, type RetryRunner, } from "../infra/retry-policy.js"; diff --git a/src/plugin-sdk/runtime.ts b/src/plugin-sdk/runtime.ts index 40dec0edbac..84fcb415334 100644 --- a/src/plugin-sdk/runtime.ts +++ b/src/plugin-sdk/runtime.ts @@ -19,6 +19,7 @@ export { } from "../globals.js"; export * from "../logging.js"; export { waitForAbortSignal } from "../infra/abort-signal.js"; +export { createBackupArchive } from "../infra/backup-create.js"; export { detectPluginInstallPathIssue, formatPluginInstallPathIssue, @@ -26,11 +27,6 @@ export { export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; export { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; export { removePluginFromConfig } from "../plugins/uninstall.js"; -export { - isDiscordMutableAllowEntry, - isSlackMutableAllowEntry, - isZalouserMutableGroupEntry, -} from "../security/mutable-allowlist-detectors.js"; /** Minimal logger contract accepted by runtime-adapter helpers. */ type LoggerLike = { diff --git a/src/plugin-sdk/security-runtime.ts b/src/plugin-sdk/security-runtime.ts index 014ba886135..c245c5e1a1d 100644 --- a/src/plugin-sdk/security-runtime.ts +++ b/src/plugin-sdk/security-runtime.ts @@ -1,5 +1,9 @@ // Public security/policy helpers for plugins that need shared trust and DM gating logic. +export * from "../secrets/channel-secret-collector-runtime.js"; +export * from "../secrets/runtime-shared.js"; +export * from "../secrets/shared.js"; +export type * from "../secrets/target-registry-types.js"; export * from "../security/channel-metadata.js"; export * from "../security/context-visibility.js"; export * from "../security/dm-policy-shared.js"; diff --git a/src/plugin-sdk/setup-tools.ts b/src/plugin-sdk/setup-tools.ts index d2a625c608d..89f463d5f06 100644 --- a/src/plugin-sdk/setup-tools.ts +++ b/src/plugin-sdk/setup-tools.ts @@ -1,4 +1,6 @@ export { formatCliCommand } from "../cli/command-format.js"; +export { extractArchive } from "../infra/archive.js"; +export { resolveBrewExecutable } from "../infra/brew.js"; export { detectBinary } from "../plugins/setup-binary.js"; -export { installSignalCli } from "../plugins/signal-cli-install.js"; export { formatDocsLink } from "../terminal/links.js"; +export { CONFIG_DIR } from "../utils.js"; diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts index 068ed4238c1..50286b1a8eb 100644 --- a/src/plugin-sdk/setup.ts +++ b/src/plugin-sdk/setup.ts @@ -20,12 +20,12 @@ export type { export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { formatCliCommand } from "../cli/command-format.js"; export { detectBinary } from "../plugins/setup-binary.js"; -export { installSignalCli } from "../plugins/signal-cli-install.js"; export { formatDocsLink } from "../terminal/links.js"; export { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; export { normalizeE164, pathExists } from "../utils.js"; export { + moveSingleAccountChannelSectionToDefaultAccount, applyAccountNameToChannelSection, applySetupAccountConfigPatch, createEnvPatchedAccountSetupAdapter, @@ -64,6 +64,7 @@ export { patchNestedChannelConfigSection, patchTopLevelChannelConfigSection, patchChannelConfigForAccount, + promptAccountId, promptLegacyChannelAllowFrom, promptLegacyChannelAllowFromForAccount, promptParsedAllowFromForAccount, @@ -88,6 +89,7 @@ export { setTopLevelChannelGroupPolicy, splitSetupEntries, } from "../channels/plugins/setup-wizard-helpers.js"; +export { promptChannelAccessConfig } from "../channels/plugins/setup-group-access.js"; export { createAllowlistSetupWizardProxy } from "../channels/plugins/setup-wizard-proxy.js"; export { createDelegatedFinalize, diff --git a/src/plugin-sdk/signal-core.ts b/src/plugin-sdk/signal-core.ts deleted file mode 100644 index c7b72e5e9fa..00000000000 --- a/src/plugin-sdk/signal-core.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Private helper surface for the bundled signal plugin. -// Keep this list additive and scoped to the bundled Signal surface. - -export type { SignalAccountConfig } from "../config/types.js"; -export type { ChannelPlugin } from "./channel-plugin-common.js"; -export { - DEFAULT_ACCOUNT_ID, - PAIRING_APPROVED_MESSAGE, - buildChannelConfigSchema, - deleteAccountFromConfigSection, - getChatChannelMeta, - setAccountEnabledInConfigSection, -} from "./channel-plugin-common.js"; -export { SignalConfigSchema } from "../config/zod-schema.providers-core.js"; -export { - looksLikeSignalTargetId, - normalizeSignalMessagingTarget, -} from "../channels/plugins/normalize/signal.js"; -export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; -export { normalizeE164 } from "../utils.js"; -export { - buildBaseAccountStatusSnapshot, - buildBaseChannelStatusSummary, - collectStatusIssuesFromLastError, - createDefaultChannelRuntimeState, -} from "./status-helpers.js"; diff --git a/src/plugin-sdk/ssrf-runtime.ts b/src/plugin-sdk/ssrf-runtime.ts index 6f4fb1ce64f..bbb2e784181 100644 --- a/src/plugin-sdk/ssrf-runtime.ts +++ b/src/plugin-sdk/ssrf-runtime.ts @@ -17,3 +17,4 @@ export { buildHostnameAllowlistPolicyFromSuffixAllowlist, ssrfPolicyFromAllowPrivateNetwork, } from "./ssrf-policy.js"; +export { isPrivateOrLoopbackHost } from "../gateway/net.js"; diff --git a/src/plugin-sdk/telegram-command-config.ts b/src/plugin-sdk/telegram-command-config.ts new file mode 100644 index 00000000000..c5a32b686f8 --- /dev/null +++ b/src/plugin-sdk/telegram-command-config.ts @@ -0,0 +1,6 @@ +export { + TELEGRAM_COMMAND_NAME_PATTERN, + normalizeTelegramCommandDescription, + normalizeTelegramCommandName, + resolveTelegramCustomCommands, +} from "../../extensions/telegram/src/command-config.js"; diff --git a/src/plugin-sdk/testing.ts b/src/plugin-sdk/testing.ts index f53dc7ea63a..0188fbb95c0 100644 --- a/src/plugin-sdk/testing.ts +++ b/src/plugin-sdk/testing.ts @@ -6,7 +6,6 @@ export { expectChannelInboundContextContract, primeChannelOutboundSendMock, } from "../channels/plugins/contracts/test-helpers.js"; -export { createSlackOutboundPayloadHarness } from "../channels/plugins/contracts/slack-outbound-harness.js"; export { buildDispatchInboundCaptureMock } from "../channels/plugins/contracts/inbound-testkit.js"; export { createCliRuntimeCapture, @@ -47,10 +46,6 @@ export { buildCommandTestParams } from "../auto-reply/reply/commands-spawn.test- export { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js"; export { jsonResponse, requestBodyText, requestUrl } from "../test-helpers/http.js"; export { mockPinnedHostnameResolution } from "../test-helpers/ssrf.js"; -export { - createWhatsAppPollFixture, - expectWhatsAppPollSent, -} from "../test-helpers/whatsapp-outbound.js"; export { createWindowsCmdShimFixture } from "../test-helpers/windows-cmd-shim.js"; export { installCommonResolveTargetErrorCases } from "../test-helpers/resolve-target-error-cases.js"; export { sanitizeTerminalText } from "../terminal/safe-text.js"; diff --git a/src/plugin-sdk/text-runtime.ts b/src/plugin-sdk/text-runtime.ts index 080d5f88551..24f052756e1 100644 --- a/src/plugin-sdk/text-runtime.ts +++ b/src/plugin-sdk/text-runtime.ts @@ -9,7 +9,6 @@ export * from "../markdown/ir.js"; export * from "../markdown/render-aware-chunking.js"; export * from "../markdown/render.js"; export * from "../markdown/tables.js"; -export * from "../markdown/whatsapp.js"; export * from "../shared/global-singleton.js"; export * from "../shared/scoped-expiring-id-cache.js"; export * from "../shared/string-normalization.js"; @@ -22,8 +21,29 @@ export * from "../shared/text/strip-markdown.js"; export * from "../terminal/safe-text.js"; export * from "../infra/system-message.ts"; export * from "../utils/directive-tags.js"; -export * from "../utils.js"; export * from "../utils/chunk-items.js"; export * from "../utils/fetch-timeout.js"; export * from "../utils/reaction-level.js"; export * from "../utils/with-timeout.js"; +export { + CONFIG_DIR, + clamp, + clampInt, + clampNumber, + displayPath, + displayString, + ensureDir, + escapeRegExp, + isRecord, + normalizeE164, + pathExists, + resolveConfigDir, + resolveHomeDir, + resolveUserPath, + safeParseJson, + shortenHomeInString, + shortenHomePath, + sleep, + sliceUtf16Safe, + truncateUtf16Safe, +} from "../utils.js"; diff --git a/src/plugin-sdk/whatsapp-targets-shared.ts b/src/plugin-sdk/whatsapp-targets-shared.ts deleted file mode 100644 index bc4e30a0c4f..00000000000 --- a/src/plugin-sdk/whatsapp-targets-shared.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { normalizeE164 } from "../utils.js"; - -const WHATSAPP_USER_JID_RE = /^(\d+)(?::\d+)?@s\.whatsapp\.net$/i; -const WHATSAPP_LID_RE = /^(\d+)@lid$/i; - -function stripWhatsAppTargetPrefixes(value: string): string { - let candidate = value.trim(); - for (;;) { - const previous = candidate; - candidate = candidate.replace(/^whatsapp:/i, "").trim(); - if (candidate === previous) { - return candidate; - } - } -} - -export function isWhatsAppGroupJid(value: string): boolean { - const candidate = stripWhatsAppTargetPrefixes(value); - const lower = candidate.toLowerCase(); - if (!lower.endsWith("@g.us")) { - return false; - } - const localPart = candidate.slice(0, candidate.length - "@g.us".length); - if (!localPart || localPart.includes("@")) { - return false; - } - return /^[0-9]+(-[0-9]+)*$/.test(localPart); -} - -export function isWhatsAppUserTarget(value: string): boolean { - const candidate = stripWhatsAppTargetPrefixes(value); - return WHATSAPP_USER_JID_RE.test(candidate) || WHATSAPP_LID_RE.test(candidate); -} - -function extractUserJidPhone(jid: string): string | null { - const userMatch = jid.match(WHATSAPP_USER_JID_RE); - if (userMatch) { - return userMatch[1]; - } - const lidMatch = jid.match(WHATSAPP_LID_RE); - if (lidMatch) { - return lidMatch[1]; - } - return null; -} - -export function normalizeWhatsAppTarget(value: string): string | null { - const candidate = stripWhatsAppTargetPrefixes(value); - if (!candidate) { - return null; - } - if (isWhatsAppGroupJid(candidate)) { - const localPart = candidate.slice(0, candidate.length - "@g.us".length); - return `${localPart}@g.us`; - } - if (isWhatsAppUserTarget(candidate)) { - const phone = extractUserJidPhone(candidate); - if (!phone) { - return null; - } - const normalized = normalizeE164(phone); - return normalized.length > 1 ? normalized : null; - } - if (candidate.includes("@")) { - return null; - } - const normalized = normalizeE164(candidate); - return normalized.length > 1 ? normalized : null; -} diff --git a/src/plugin-sdk/whatsapp-targets.ts b/src/plugin-sdk/whatsapp-targets.ts deleted file mode 100644 index 6ab2ac22cfc..00000000000 --- a/src/plugin-sdk/whatsapp-targets.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Generated by scripts/generate-plugin-sdk-facades.mjs. Do not edit manually. -export { - isWhatsAppGroupJid, - isWhatsAppUserTarget, - normalizeWhatsAppTarget, -} from "./whatsapp-targets-shared.js"; diff --git a/src/plugin-sdk/xai-model-id.ts b/src/plugin-sdk/xai-model-id.ts index 9bf95e28a32..d5f22ac7a4f 100644 --- a/src/plugin-sdk/xai-model-id.ts +++ b/src/plugin-sdk/xai-model-id.ts @@ -1,21 +1 @@ -export function normalizeXaiModelId(id: string): string { - if (id === "grok-4-fast-reasoning") { - return "grok-4-fast"; - } - if (id === "grok-4-1-fast-reasoning") { - return "grok-4-1-fast"; - } - if (id === "grok-4.20-experimental-beta-0304-reasoning") { - return "grok-4.20-beta-latest-reasoning"; - } - if (id === "grok-4.20-experimental-beta-0304-non-reasoning") { - return "grok-4.20-beta-latest-non-reasoning"; - } - if (id === "grok-4.20-reasoning") { - return "grok-4.20-beta-latest-reasoning"; - } - if (id === "grok-4.20-non-reasoning") { - return "grok-4.20-beta-latest-non-reasoning"; - } - return id; -} +export { normalizeNativeXaiModelId as normalizeXaiModelId } from "./provider-model-shared.js"; diff --git a/src/plugins/interactive-registry.ts b/src/plugins/interactive-registry.ts index 7583421253b..df64ffe5d0a 100644 --- a/src/plugins/interactive-registry.ts +++ b/src/plugins/interactive-registry.ts @@ -72,34 +72,14 @@ export function registerPluginInteractiveHandler( error: `Interactive handler namespace "${namespace}" already registered by plugin "${existing.pluginId}"`, }; } - if (registration.channel === "telegram") { - interactiveHandlers.set(key, { - ...registration, - namespace, - channel: "telegram", - pluginId, - pluginName: opts?.pluginName, - pluginRoot: opts?.pluginRoot, - }); - } else if (registration.channel === "slack") { - interactiveHandlers.set(key, { - ...registration, - namespace, - channel: "slack", - pluginId, - pluginName: opts?.pluginName, - pluginRoot: opts?.pluginRoot, - }); - } else { - interactiveHandlers.set(key, { - ...registration, - namespace, - channel: "discord", - pluginId, - pluginName: opts?.pluginName, - pluginRoot: opts?.pluginRoot, - }); - } + interactiveHandlers.set(key, { + ...registration, + namespace, + channel: registration.channel.trim().toLowerCase(), + pluginId, + pluginName: opts?.pluginName, + pluginRoot: opts?.pluginRoot, + }); return { ok: true }; } diff --git a/src/secrets/channel-secret-collector-runtime.ts b/src/secrets/channel-secret-collector-runtime.ts new file mode 100644 index 00000000000..1e2f0e72e39 --- /dev/null +++ b/src/secrets/channel-secret-collector-runtime.ts @@ -0,0 +1,318 @@ +import { coerceSecretRef } from "../config/types.secrets.js"; +import { collectTtsApiKeyAssignments } from "./runtime-config-collectors-tts.js"; +import { + collectSecretInputAssignment, + hasOwnProperty, + isChannelAccountEffectivelyEnabled, + isEnabledFlag, + type ResolverContext, + type SecretDefaults, +} from "./runtime-shared.js"; +import { isRecord } from "./shared.js"; + +export type ChannelAccountEntry = { + accountId: string; + account: Record; + enabled: boolean; +}; + +export type ChannelAccountSurface = { + hasExplicitAccounts: boolean; + channelEnabled: boolean; + accounts: ChannelAccountEntry[]; +}; + +export type ChannelAccountPredicate = (entry: ChannelAccountEntry) => boolean; + +export function getChannelRecord( + config: { channels?: Record }, + channelKey: string, +): Record | undefined { + const channels = config.channels; + if (!isRecord(channels)) { + return undefined; + } + const channel = channels[channelKey]; + return isRecord(channel) ? channel : undefined; +} + +export function getChannelSurface( + config: { channels?: Record }, + channelKey: string, +): { channel: Record; surface: ChannelAccountSurface } | null { + const channel = getChannelRecord(config, channelKey); + if (!channel) { + return null; + } + return { + channel, + surface: resolveChannelAccountSurface(channel), + }; +} + +export function resolveChannelAccountSurface( + channel: Record, +): ChannelAccountSurface { + const channelEnabled = isEnabledFlag(channel); + const accounts = channel.accounts; + if (!isRecord(accounts) || Object.keys(accounts).length === 0) { + return { + hasExplicitAccounts: false, + channelEnabled, + accounts: [{ accountId: "default", account: channel, enabled: channelEnabled }], + }; + } + const accountEntries: ChannelAccountEntry[] = []; + for (const [accountId, account] of Object.entries(accounts)) { + if (!isRecord(account)) { + continue; + } + accountEntries.push({ + accountId, + account, + enabled: isChannelAccountEffectivelyEnabled(channel, account), + }); + } + return { + hasExplicitAccounts: true, + channelEnabled, + accounts: accountEntries, + }; +} + +export function isBaseFieldActiveForChannelSurface( + surface: ChannelAccountSurface, + rootKey: string, +): boolean { + if (!surface.channelEnabled) { + return false; + } + if (!surface.hasExplicitAccounts) { + return true; + } + return surface.accounts.some( + ({ account, enabled }) => enabled && !hasOwnProperty(account, rootKey), + ); +} + +export function normalizeSecretStringValue(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +export function hasConfiguredSecretInputValue( + value: unknown, + defaults: SecretDefaults | undefined, +): boolean { + return normalizeSecretStringValue(value).length > 0 || coerceSecretRef(value, defaults) !== null; +} + +export function collectSimpleChannelFieldAssignments(params: { + channelKey: string; + field: string; + channel: Record; + surface: ChannelAccountSurface; + defaults: SecretDefaults | undefined; + context: ResolverContext; + topInactiveReason: string; + accountInactiveReason: string; +}): void { + collectSecretInputAssignment({ + value: params.channel[params.field], + path: `channels.${params.channelKey}.${params.field}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: isBaseFieldActiveForChannelSurface(params.surface, params.field), + inactiveReason: params.topInactiveReason, + apply: (value) => { + params.channel[params.field] = value; + }, + }); + if (!params.surface.hasExplicitAccounts) { + return; + } + for (const { accountId, account, enabled } of params.surface.accounts) { + if (!hasOwnProperty(account, params.field)) { + continue; + } + collectSecretInputAssignment({ + value: account[params.field], + path: `channels.${params.channelKey}.accounts.${accountId}.${params.field}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: enabled, + inactiveReason: params.accountInactiveReason, + apply: (value) => { + account[params.field] = value; + }, + }); + } +} + +function isConditionalTopLevelFieldActive(params: { + surface: ChannelAccountSurface; + activeWithoutAccounts: boolean; + inheritedAccountActive: ChannelAccountPredicate; +}): boolean { + if (!params.surface.channelEnabled) { + return false; + } + if (!params.surface.hasExplicitAccounts) { + return params.activeWithoutAccounts; + } + return params.surface.accounts.some(params.inheritedAccountActive); +} + +export function collectConditionalChannelFieldAssignments(params: { + channelKey: string; + field: string; + channel: Record; + surface: ChannelAccountSurface; + defaults: SecretDefaults | undefined; + context: ResolverContext; + topLevelActiveWithoutAccounts: boolean; + topLevelInheritedAccountActive: ChannelAccountPredicate; + accountActive: ChannelAccountPredicate; + topInactiveReason: string; + accountInactiveReason: string | ((entry: ChannelAccountEntry) => string); +}): void { + collectSecretInputAssignment({ + value: params.channel[params.field], + path: `channels.${params.channelKey}.${params.field}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: isConditionalTopLevelFieldActive({ + surface: params.surface, + activeWithoutAccounts: params.topLevelActiveWithoutAccounts, + inheritedAccountActive: params.topLevelInheritedAccountActive, + }), + inactiveReason: params.topInactiveReason, + apply: (value) => { + params.channel[params.field] = value; + }, + }); + if (!params.surface.hasExplicitAccounts) { + return; + } + for (const entry of params.surface.accounts) { + if (!hasOwnProperty(entry.account, params.field)) { + continue; + } + collectSecretInputAssignment({ + value: entry.account[params.field], + path: `channels.${params.channelKey}.accounts.${entry.accountId}.${params.field}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: params.accountActive(entry), + inactiveReason: + typeof params.accountInactiveReason === "function" + ? params.accountInactiveReason(entry) + : params.accountInactiveReason, + apply: (value) => { + entry.account[params.field] = value; + }, + }); + } +} + +export function collectNestedChannelFieldAssignments(params: { + channelKey: string; + nestedKey: string; + field: string; + channel: Record; + surface: ChannelAccountSurface; + defaults: SecretDefaults | undefined; + context: ResolverContext; + topLevelActive: boolean; + topInactiveReason: string; + accountActive: ChannelAccountPredicate; + accountInactiveReason: string | ((entry: ChannelAccountEntry) => string); +}): void { + const topLevelNested = params.channel[params.nestedKey]; + if (isRecord(topLevelNested)) { + collectSecretInputAssignment({ + value: topLevelNested[params.field], + path: `channels.${params.channelKey}.${params.nestedKey}.${params.field}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: params.topLevelActive, + inactiveReason: params.topInactiveReason, + apply: (value) => { + topLevelNested[params.field] = value; + }, + }); + } + if (!params.surface.hasExplicitAccounts) { + return; + } + for (const entry of params.surface.accounts) { + const nested = entry.account[params.nestedKey]; + if (!isRecord(nested)) { + continue; + } + collectSecretInputAssignment({ + value: nested[params.field], + path: `channels.${params.channelKey}.accounts.${entry.accountId}.${params.nestedKey}.${params.field}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: params.accountActive(entry), + inactiveReason: + typeof params.accountInactiveReason === "function" + ? params.accountInactiveReason(entry) + : params.accountInactiveReason, + apply: (value) => { + nested[params.field] = value; + }, + }); + } +} + +export function collectNestedChannelTtsAssignments(params: { + channelKey: string; + nestedKey: string; + channel: Record; + surface: ChannelAccountSurface; + defaults: SecretDefaults | undefined; + context: ResolverContext; + topLevelActive: boolean; + topInactiveReason: string; + accountActive: ChannelAccountPredicate; + accountInactiveReason: string | ((entry: ChannelAccountEntry) => string); +}): void { + const topLevelNested = params.channel[params.nestedKey]; + if (isRecord(topLevelNested) && isRecord(topLevelNested.tts)) { + collectTtsApiKeyAssignments({ + tts: topLevelNested.tts, + pathPrefix: `channels.${params.channelKey}.${params.nestedKey}.tts`, + defaults: params.defaults, + context: params.context, + active: params.topLevelActive, + inactiveReason: params.topInactiveReason, + }); + } + if (!params.surface.hasExplicitAccounts) { + return; + } + for (const entry of params.surface.accounts) { + const nested = entry.account[params.nestedKey]; + if (!isRecord(nested) || !isRecord(nested.tts)) { + continue; + } + collectTtsApiKeyAssignments({ + tts: nested.tts, + pathPrefix: `channels.${params.channelKey}.accounts.${entry.accountId}.${params.nestedKey}.tts`, + defaults: params.defaults, + context: params.context, + active: params.accountActive(entry), + inactiveReason: + typeof params.accountInactiveReason === "function" + ? params.accountInactiveReason(entry) + : params.accountInactiveReason, + }); + } +} diff --git a/src/secrets/credential-matrix.ts b/src/secrets/credential-matrix.ts index 54d51c63c0d..1c4bd49bf98 100644 --- a/src/secrets/credential-matrix.ts +++ b/src/secrets/credential-matrix.ts @@ -41,8 +41,8 @@ export function buildSecretRefCredentialMatrix(): SecretRefCredentialMatrixDocum ...(entry.authProfileType ? { when: { type: entry.authProfileType } } : {}), secretShape: entry.secretShape, optIn: true as const, - ...(entry.id.startsWith("channels.googlechat.") - ? { notes: "Google Chat compatibility exception: sibling ref field remains canonical." } + ...(entry.secretShape === "sibling_ref" && entry.refPathPattern + ? { notes: "Compatibility exception: sibling ref field remains canonical." } : {}), }; }) diff --git a/src/secrets/plan.ts b/src/secrets/plan.ts index 4f576a4f25f..5f87d3be28b 100644 --- a/src/secrets/plan.ts +++ b/src/secrets/plan.ts @@ -35,9 +35,7 @@ export type SecretsPlanTarget = { * For provider targets, used to scrub auth-profile/static residues. */ providerId?: string; - /** - * For googlechat account-scoped targets. - */ + /** For account-scoped channel targets. */ accountId?: string; /** * Optional auth-profile provider value used when creating new auth profile mappings. diff --git a/src/secrets/runtime-config-collectors-channels.ts b/src/secrets/runtime-config-collectors-channels.ts index 6e8e0578c2e..9f5b130aac8 100644 --- a/src/secrets/runtime-config-collectors-channels.ts +++ b/src/secrets/runtime-config-collectors-channels.ts @@ -1,506 +1,33 @@ +import { getBundledChannelContractSurfaces } from "../channels/plugins/contract-surfaces.js"; import type { OpenClawConfig } from "../config/config.js"; -import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; -import { getMatrixScopedEnvVarNames } from "../infra/matrix-config-helpers.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/account-id.js"; -import { collectTtsApiKeyAssignments } from "./runtime-config-collectors-tts.js"; import { + collectConditionalChannelFieldAssignments, + collectNestedChannelFieldAssignments, + collectSimpleChannelFieldAssignments, + getChannelRecord, + getChannelSurface, + isBaseFieldActiveForChannelSurface, + type ChannelAccountEntry, +} from "./channel-secret-collector-runtime.js"; +import { + isEnabledFlag, collectSecretInputAssignment, hasOwnProperty, - isChannelAccountEffectivelyEnabled, - isEnabledFlag, - pushAssignment, - pushInactiveSurfaceWarning, - pushWarning, type ResolverContext, type SecretDefaults, } from "./runtime-shared.js"; import { isRecord } from "./shared.js"; -type GoogleChatAccountLike = { - serviceAccount?: unknown; - serviceAccountRef?: unknown; - accounts?: Record; +type ChannelRuntimeConfigCollectorSurface = { + collectRuntimeConfigAssignments?: (params: { + config: OpenClawConfig; + defaults: SecretDefaults | undefined; + context: ResolverContext; + }) => void; }; -type ChannelAccountEntry = { - accountId: string; - account: Record; - enabled: boolean; -}; - -type ChannelAccountSurface = { - hasExplicitAccounts: boolean; - channelEnabled: boolean; - accounts: ChannelAccountEntry[]; -}; - -type ChannelAccountPredicate = (entry: ChannelAccountEntry) => boolean; - -function getChannelRecord( - config: OpenClawConfig, - channelKey: string, -): Record | undefined { - const channels = config.channels as Record | undefined; - if (!isRecord(channels)) { - return undefined; - } - const channel = channels[channelKey]; - return isRecord(channel) ? channel : undefined; -} - -function getChannelSurface( - config: OpenClawConfig, - channelKey: string, -): { channel: Record; surface: ChannelAccountSurface } | null { - const channel = getChannelRecord(config, channelKey); - if (!channel) { - return null; - } - return { - channel, - surface: resolveChannelAccountSurface(channel), - }; -} - -function resolveChannelAccountSurface(channel: Record): ChannelAccountSurface { - const channelEnabled = isEnabledFlag(channel); - const accounts = channel.accounts; - if (!isRecord(accounts) || Object.keys(accounts).length === 0) { - return { - hasExplicitAccounts: false, - channelEnabled, - accounts: [{ accountId: "default", account: channel, enabled: channelEnabled }], - }; - } - const accountEntries: ChannelAccountEntry[] = []; - for (const [accountId, account] of Object.entries(accounts)) { - if (!isRecord(account)) { - continue; - } - accountEntries.push({ - accountId, - account, - enabled: isChannelAccountEffectivelyEnabled(channel, account), - }); - } - return { - hasExplicitAccounts: true, - channelEnabled, - accounts: accountEntries, - }; -} - -function isBaseFieldActiveForChannelSurface( - surface: ChannelAccountSurface, - rootKey: string, -): boolean { - if (!surface.channelEnabled) { - return false; - } - if (!surface.hasExplicitAccounts) { - return true; - } - return surface.accounts.some( - ({ account, enabled }) => enabled && !hasOwnProperty(account, rootKey), - ); -} - -function normalizeSecretStringValue(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -function hasConfiguredSecretInputValue( - value: unknown, - defaults: SecretDefaults | undefined, -): boolean { - return normalizeSecretStringValue(value).length > 0 || coerceSecretRef(value, defaults) !== null; -} - -function collectSimpleChannelFieldAssignments(params: { - channelKey: string; - field: string; - channel: Record; - surface: ChannelAccountSurface; - defaults: SecretDefaults | undefined; - context: ResolverContext; - topInactiveReason: string; - accountInactiveReason: string; -}): void { - collectSecretInputAssignment({ - value: params.channel[params.field], - path: `channels.${params.channelKey}.${params.field}`, - expected: "string", - defaults: params.defaults, - context: params.context, - active: isBaseFieldActiveForChannelSurface(params.surface, params.field), - inactiveReason: params.topInactiveReason, - apply: (value) => { - params.channel[params.field] = value; - }, - }); - if (!params.surface.hasExplicitAccounts) { - return; - } - for (const { accountId, account, enabled } of params.surface.accounts) { - if (!hasOwnProperty(account, params.field)) { - continue; - } - collectSecretInputAssignment({ - value: account[params.field], - path: `channels.${params.channelKey}.accounts.${accountId}.${params.field}`, - expected: "string", - defaults: params.defaults, - context: params.context, - active: enabled, - inactiveReason: params.accountInactiveReason, - apply: (value) => { - account[params.field] = value; - }, - }); - } -} - -function isConditionalTopLevelFieldActive(params: { - surface: ChannelAccountSurface; - activeWithoutAccounts: boolean; - inheritedAccountActive: ChannelAccountPredicate; -}): boolean { - if (!params.surface.channelEnabled) { - return false; - } - if (!params.surface.hasExplicitAccounts) { - return params.activeWithoutAccounts; - } - return params.surface.accounts.some(params.inheritedAccountActive); -} - -function collectConditionalChannelFieldAssignments(params: { - channelKey: string; - field: string; - channel: Record; - surface: ChannelAccountSurface; - defaults: SecretDefaults | undefined; - context: ResolverContext; - topLevelActiveWithoutAccounts: boolean; - topLevelInheritedAccountActive: ChannelAccountPredicate; - accountActive: ChannelAccountPredicate; - topInactiveReason: string; - accountInactiveReason: string | ((entry: ChannelAccountEntry) => string); -}): void { - collectSecretInputAssignment({ - value: params.channel[params.field], - path: `channels.${params.channelKey}.${params.field}`, - expected: "string", - defaults: params.defaults, - context: params.context, - active: isConditionalTopLevelFieldActive({ - surface: params.surface, - activeWithoutAccounts: params.topLevelActiveWithoutAccounts, - inheritedAccountActive: params.topLevelInheritedAccountActive, - }), - inactiveReason: params.topInactiveReason, - apply: (value) => { - params.channel[params.field] = value; - }, - }); - if (!params.surface.hasExplicitAccounts) { - return; - } - for (const entry of params.surface.accounts) { - if (!hasOwnProperty(entry.account, params.field)) { - continue; - } - collectSecretInputAssignment({ - value: entry.account[params.field], - path: `channels.${params.channelKey}.accounts.${entry.accountId}.${params.field}`, - expected: "string", - defaults: params.defaults, - context: params.context, - active: params.accountActive(entry), - inactiveReason: - typeof params.accountInactiveReason === "function" - ? params.accountInactiveReason(entry) - : params.accountInactiveReason, - apply: (value) => { - entry.account[params.field] = value; - }, - }); - } -} - -function collectNestedChannelFieldAssignments(params: { - channelKey: string; - nestedKey: string; - field: string; - channel: Record; - surface: ChannelAccountSurface; - defaults: SecretDefaults | undefined; - context: ResolverContext; - topLevelActive: boolean; - topInactiveReason: string; - accountActive: ChannelAccountPredicate; - accountInactiveReason: string | ((entry: ChannelAccountEntry) => string); -}): void { - const topLevelNested = params.channel[params.nestedKey]; - if (isRecord(topLevelNested)) { - collectSecretInputAssignment({ - value: topLevelNested[params.field], - path: `channels.${params.channelKey}.${params.nestedKey}.${params.field}`, - expected: "string", - defaults: params.defaults, - context: params.context, - active: params.topLevelActive, - inactiveReason: params.topInactiveReason, - apply: (value) => { - topLevelNested[params.field] = value; - }, - }); - } - if (!params.surface.hasExplicitAccounts) { - return; - } - for (const entry of params.surface.accounts) { - const nested = entry.account[params.nestedKey]; - if (!isRecord(nested)) { - continue; - } - collectSecretInputAssignment({ - value: nested[params.field], - path: `channels.${params.channelKey}.accounts.${entry.accountId}.${params.nestedKey}.${params.field}`, - expected: "string", - defaults: params.defaults, - context: params.context, - active: params.accountActive(entry), - inactiveReason: - typeof params.accountInactiveReason === "function" - ? params.accountInactiveReason(entry) - : params.accountInactiveReason, - apply: (value) => { - nested[params.field] = value; - }, - }); - } -} - -function collectNestedChannelTtsAssignments(params: { - channelKey: string; - nestedKey: string; - channel: Record; - surface: ChannelAccountSurface; - defaults: SecretDefaults | undefined; - context: ResolverContext; - topLevelActive: boolean; - topInactiveReason: string; - accountActive: ChannelAccountPredicate; - accountInactiveReason: string | ((entry: ChannelAccountEntry) => string); -}): void { - const topLevelNested = params.channel[params.nestedKey]; - if (isRecord(topLevelNested) && isRecord(topLevelNested.tts)) { - collectTtsApiKeyAssignments({ - tts: topLevelNested.tts, - pathPrefix: `channels.${params.channelKey}.${params.nestedKey}.tts`, - defaults: params.defaults, - context: params.context, - active: params.topLevelActive, - inactiveReason: params.topInactiveReason, - }); - } - if (!params.surface.hasExplicitAccounts) { - return; - } - for (const entry of params.surface.accounts) { - const nested = entry.account[params.nestedKey]; - if (!isRecord(nested) || !isRecord(nested.tts)) { - continue; - } - collectTtsApiKeyAssignments({ - tts: nested.tts, - pathPrefix: `channels.${params.channelKey}.accounts.${entry.accountId}.${params.nestedKey}.tts`, - defaults: params.defaults, - context: params.context, - active: params.accountActive(entry), - inactiveReason: - typeof params.accountInactiveReason === "function" - ? params.accountInactiveReason(entry) - : params.accountInactiveReason, - }); - } -} - -function collectTelegramAssignments(params: { - config: OpenClawConfig; - defaults: SecretDefaults | undefined; - context: ResolverContext; -}): void { - const resolved = getChannelSurface(params.config, "telegram"); - if (!resolved) { - return; - } - const { channel: telegram, surface } = resolved; - const baseTokenFile = typeof telegram.tokenFile === "string" ? telegram.tokenFile.trim() : ""; - const accountTokenFile = (account: Record) => - typeof account.tokenFile === "string" ? account.tokenFile.trim() : ""; - collectConditionalChannelFieldAssignments({ - channelKey: "telegram", - field: "botToken", - channel: telegram, - surface, - defaults: params.defaults, - context: params.context, - topLevelActiveWithoutAccounts: baseTokenFile.length === 0, - topLevelInheritedAccountActive: ({ account, enabled }) => { - if (!enabled || baseTokenFile.length > 0) { - return false; - } - const accountBotTokenConfigured = hasConfiguredSecretInputValue( - account.botToken, - params.defaults, - ); - return !accountBotTokenConfigured && accountTokenFile(account).length === 0; - }, - accountActive: ({ account, enabled }) => enabled && accountTokenFile(account).length === 0, - topInactiveReason: - "no enabled Telegram surface inherits this top-level botToken (tokenFile is configured).", - accountInactiveReason: "Telegram account is disabled or tokenFile is configured.", - }); - const baseWebhookUrl = typeof telegram.webhookUrl === "string" ? telegram.webhookUrl.trim() : ""; - const accountWebhookUrl = (account: Record) => - hasOwnProperty(account, "webhookUrl") - ? typeof account.webhookUrl === "string" - ? account.webhookUrl.trim() - : "" - : baseWebhookUrl; - collectConditionalChannelFieldAssignments({ - channelKey: "telegram", - field: "webhookSecret", - channel: telegram, - surface, - defaults: params.defaults, - context: params.context, - topLevelActiveWithoutAccounts: baseWebhookUrl.length > 0, - topLevelInheritedAccountActive: ({ account, enabled }) => - enabled && !hasOwnProperty(account, "webhookSecret") && accountWebhookUrl(account).length > 0, - accountActive: ({ account, enabled }) => enabled && accountWebhookUrl(account).length > 0, - topInactiveReason: - "no enabled Telegram webhook surface inherits this top-level webhookSecret (webhook mode is not active).", - accountInactiveReason: - "Telegram account is disabled or webhook mode is not active for this account.", - }); -} - -function collectSlackAssignments(params: { - config: OpenClawConfig; - defaults: SecretDefaults | undefined; - context: ResolverContext; -}): void { - const resolved = getChannelSurface(params.config, "slack"); - if (!resolved) { - return; - } - const { channel: slack, surface } = resolved; - const baseMode = slack.mode === "http" || slack.mode === "socket" ? slack.mode : "socket"; - const fields = ["botToken", "userToken"] as const; - for (const field of fields) { - collectSimpleChannelFieldAssignments({ - channelKey: "slack", - field, - channel: slack, - surface, - defaults: params.defaults, - context: params.context, - topInactiveReason: `no enabled account inherits this top-level Slack ${field}.`, - accountInactiveReason: "Slack account is disabled.", - }); - } - const resolveAccountMode = (account: Record) => - account.mode === "http" || account.mode === "socket" ? account.mode : baseMode; - collectConditionalChannelFieldAssignments({ - channelKey: "slack", - field: "appToken", - channel: slack, - surface, - defaults: params.defaults, - context: params.context, - topLevelActiveWithoutAccounts: baseMode !== "http", - topLevelInheritedAccountActive: ({ account, enabled }) => - enabled && !hasOwnProperty(account, "appToken") && resolveAccountMode(account) !== "http", - accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) !== "http", - topInactiveReason: "no enabled Slack socket-mode surface inherits this top-level appToken.", - accountInactiveReason: "Slack account is disabled or not running in socket mode.", - }); - collectConditionalChannelFieldAssignments({ - channelKey: "slack", - field: "signingSecret", - channel: slack, - surface, - defaults: params.defaults, - context: params.context, - topLevelActiveWithoutAccounts: baseMode === "http", - topLevelInheritedAccountActive: ({ account, enabled }) => - enabled && - !hasOwnProperty(account, "signingSecret") && - resolveAccountMode(account) === "http", - accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "http", - topInactiveReason: "no enabled Slack HTTP-mode surface inherits this top-level signingSecret.", - accountInactiveReason: "Slack account is disabled or not running in HTTP mode.", - }); -} - -function collectDiscordAssignments(params: { - config: OpenClawConfig; - defaults: SecretDefaults | undefined; - context: ResolverContext; -}): void { - const resolved = getChannelSurface(params.config, "discord"); - if (!resolved) { - return; - } - const { channel: discord, surface } = resolved; - collectSimpleChannelFieldAssignments({ - channelKey: "discord", - field: "token", - channel: discord, - surface, - defaults: params.defaults, - context: params.context, - topInactiveReason: "no enabled account inherits this top-level Discord token.", - accountInactiveReason: "Discord account is disabled.", - }); - collectNestedChannelFieldAssignments({ - channelKey: "discord", - nestedKey: "pluralkit", - field: "token", - channel: discord, - surface, - defaults: params.defaults, - context: params.context, - topLevelActive: - isBaseFieldActiveForChannelSurface(surface, "pluralkit") && - isRecord(discord.pluralkit) && - isEnabledFlag(discord.pluralkit), - topInactiveReason: - "no enabled Discord surface inherits this top-level PluralKit config or PluralKit is disabled.", - accountActive: ({ account, enabled }) => - enabled && isRecord(account.pluralkit) && isEnabledFlag(account.pluralkit), - accountInactiveReason: "Discord account is disabled or PluralKit is disabled for this account.", - }); - collectNestedChannelTtsAssignments({ - channelKey: "discord", - nestedKey: "voice", - channel: discord, - surface, - defaults: params.defaults, - context: params.context, - topLevelActive: - isBaseFieldActiveForChannelSurface(surface, "voice") && - isRecord(discord.voice) && - isEnabledFlag(discord.voice), - topInactiveReason: - "no enabled Discord surface inherits this top-level voice config or voice is disabled.", - accountActive: ({ account, enabled }) => - enabled && isRecord(account.voice) && isEnabledFlag(account.voice), - accountInactiveReason: "Discord account is disabled or voice is disabled for this account.", - }); +function listChannelRuntimeConfigCollectorSurfaces(): ChannelRuntimeConfigCollectorSurface[] { + return getBundledChannelContractSurfaces() as ChannelRuntimeConfigCollectorSurface[]; } function collectIrcAssignments(params: { @@ -588,249 +115,6 @@ function collectMSTeamsAssignments(params: { }); } -function collectMattermostAssignments(params: { - config: OpenClawConfig; - defaults: SecretDefaults | undefined; - context: ResolverContext; -}): void { - const resolved = getChannelSurface(params.config, "mattermost"); - if (!resolved) { - return; - } - const { channel: mattermost, surface } = resolved; - collectSimpleChannelFieldAssignments({ - channelKey: "mattermost", - field: "botToken", - channel: mattermost, - surface, - defaults: params.defaults, - context: params.context, - topInactiveReason: "no enabled account inherits this top-level Mattermost botToken.", - accountInactiveReason: "Mattermost account is disabled.", - }); -} - -function collectMatrixAssignments(params: { - config: OpenClawConfig; - defaults: SecretDefaults | undefined; - context: ResolverContext; -}): void { - const resolved = getChannelSurface(params.config, "matrix"); - if (!resolved) { - return; - } - const { channel: matrix, surface } = resolved; - const envAccessTokenConfigured = - normalizeSecretStringValue(params.context.env.MATRIX_ACCESS_TOKEN).length > 0; - const defaultScopedAccessTokenConfigured = - normalizeSecretStringValue( - params.context.env[getMatrixScopedEnvVarNames("default").accessToken], - ).length > 0; - const defaultAccountAccessTokenConfigured = surface.accounts.some( - ({ accountId, account }) => - normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID && - hasConfiguredSecretInputValue(account.accessToken, params.defaults), - ); - const baseAccessTokenConfigured = hasConfiguredSecretInputValue( - matrix.accessToken, - params.defaults, - ); - collectSecretInputAssignment({ - value: matrix.accessToken, - path: "channels.matrix.accessToken", - expected: "string", - defaults: params.defaults, - context: params.context, - active: surface.channelEnabled, - inactiveReason: "Matrix channel is disabled.", - apply: (value) => { - matrix.accessToken = value; - }, - }); - collectSecretInputAssignment({ - value: matrix.password, - path: "channels.matrix.password", - expected: "string", - defaults: params.defaults, - context: params.context, - active: - surface.channelEnabled && - !( - baseAccessTokenConfigured || - envAccessTokenConfigured || - defaultScopedAccessTokenConfigured || - defaultAccountAccessTokenConfigured - ), - inactiveReason: - "Matrix channel is disabled or access-token auth is configured for the default Matrix account.", - apply: (value) => { - matrix.password = value; - }, - }); - if (!surface.hasExplicitAccounts) { - return; - } - for (const { accountId, account, enabled } of surface.accounts) { - if (hasOwnProperty(account, "accessToken")) { - collectSecretInputAssignment({ - value: account.accessToken, - path: `channels.matrix.accounts.${accountId}.accessToken`, - expected: "string", - defaults: params.defaults, - context: params.context, - active: enabled, - inactiveReason: "Matrix account is disabled.", - apply: (value) => { - account.accessToken = value; - }, - }); - } - if (!hasOwnProperty(account, "password")) { - continue; - } - const accountAccessTokenConfigured = hasConfiguredSecretInputValue( - account.accessToken, - params.defaults, - ); - const scopedEnvAccessTokenConfigured = - normalizeSecretStringValue( - params.context.env[getMatrixScopedEnvVarNames(accountId).accessToken], - ).length > 0; - const inheritedDefaultAccountAccessTokenConfigured = - normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID && - (baseAccessTokenConfigured || envAccessTokenConfigured); - collectSecretInputAssignment({ - value: account.password, - path: `channels.matrix.accounts.${accountId}.password`, - expected: "string", - defaults: params.defaults, - context: params.context, - active: - enabled && - !( - accountAccessTokenConfigured || - scopedEnvAccessTokenConfigured || - inheritedDefaultAccountAccessTokenConfigured - ), - inactiveReason: "Matrix account is disabled or this account has an accessToken configured.", - apply: (value) => { - account.password = value; - }, - }); - } -} - -function collectZaloAssignments(params: { - config: OpenClawConfig; - defaults: SecretDefaults | undefined; - context: ResolverContext; -}): void { - const resolved = getChannelSurface(params.config, "zalo"); - if (!resolved) { - return; - } - const { channel: zalo, surface } = resolved; - collectConditionalChannelFieldAssignments({ - channelKey: "zalo", - field: "botToken", - channel: zalo, - surface, - defaults: params.defaults, - context: params.context, - topLevelActiveWithoutAccounts: true, - topLevelInheritedAccountActive: ({ account, enabled }) => - enabled && !hasOwnProperty(account, "botToken"), - accountActive: ({ enabled }) => enabled, - topInactiveReason: "no enabled Zalo surface inherits this top-level botToken.", - accountInactiveReason: "Zalo account is disabled.", - }); - const baseWebhookUrl = normalizeSecretStringValue(zalo.webhookUrl); - const resolveAccountWebhookUrl = (account: Record) => - hasOwnProperty(account, "webhookUrl") - ? normalizeSecretStringValue(account.webhookUrl) - : baseWebhookUrl; - collectConditionalChannelFieldAssignments({ - channelKey: "zalo", - field: "webhookSecret", - channel: zalo, - surface, - defaults: params.defaults, - context: params.context, - topLevelActiveWithoutAccounts: baseWebhookUrl.length > 0, - topLevelInheritedAccountActive: ({ account, enabled }) => - enabled && - !hasOwnProperty(account, "webhookSecret") && - resolveAccountWebhookUrl(account).length > 0, - accountActive: ({ account, enabled }) => - enabled && resolveAccountWebhookUrl(account).length > 0, - topInactiveReason: - "no enabled Zalo webhook surface inherits this top-level webhookSecret (webhook mode is not active).", - accountInactiveReason: - "Zalo account is disabled or webhook mode is not active for this account.", - }); -} - -function collectFeishuAssignments(params: { - config: OpenClawConfig; - defaults: SecretDefaults | undefined; - context: ResolverContext; -}): void { - const resolved = getChannelSurface(params.config, "feishu"); - if (!resolved) { - return; - } - const { channel: feishu, surface } = resolved; - collectSimpleChannelFieldAssignments({ - channelKey: "feishu", - field: "appSecret", - channel: feishu, - surface, - defaults: params.defaults, - context: params.context, - topInactiveReason: "no enabled account inherits this top-level Feishu appSecret.", - accountInactiveReason: "Feishu account is disabled.", - }); - const baseConnectionMode = - normalizeSecretStringValue(feishu.connectionMode) === "webhook" ? "webhook" : "websocket"; - const resolveAccountMode = (account: Record) => - hasOwnProperty(account, "connectionMode") - ? normalizeSecretStringValue(account.connectionMode) - : baseConnectionMode; - collectConditionalChannelFieldAssignments({ - channelKey: "feishu", - field: "encryptKey", - channel: feishu, - surface, - defaults: params.defaults, - context: params.context, - topLevelActiveWithoutAccounts: baseConnectionMode === "webhook", - topLevelInheritedAccountActive: ({ account, enabled }) => - enabled && - !hasOwnProperty(account, "encryptKey") && - resolveAccountMode(account) === "webhook", - accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook", - topInactiveReason: "no enabled Feishu webhook-mode surface inherits this top-level encryptKey.", - accountInactiveReason: "Feishu account is disabled or not running in webhook mode.", - }); - collectConditionalChannelFieldAssignments({ - channelKey: "feishu", - field: "verificationToken", - channel: feishu, - surface, - defaults: params.defaults, - context: params.context, - topLevelActiveWithoutAccounts: baseConnectionMode === "webhook", - topLevelInheritedAccountActive: ({ account, enabled }) => - enabled && - !hasOwnProperty(account, "verificationToken") && - resolveAccountMode(account) === "webhook", - accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook", - topInactiveReason: - "no enabled Feishu webhook-mode surface inherits this top-level verificationToken.", - accountInactiveReason: "Feishu account is disabled or not running in webhook mode.", - }); -} - function collectNextcloudTalkAssignments(params: { config: OpenClawConfig; defaults: SecretDefaults | undefined; @@ -873,121 +157,16 @@ function collectNextcloudTalkAssignments(params: { }); } -function collectGoogleChatAccountAssignment(params: { - target: GoogleChatAccountLike; - path: string; - defaults: SecretDefaults | undefined; - context: ResolverContext; - active?: boolean; - inactiveReason?: string; -}): void { - const { explicitRef, ref } = resolveSecretInputRef({ - value: params.target.serviceAccount, - refValue: params.target.serviceAccountRef, - defaults: params.defaults, - }); - if (!ref) { - return; - } - if (params.active === false) { - pushInactiveSurfaceWarning({ - context: params.context, - path: `${params.path}.serviceAccount`, - details: params.inactiveReason, - }); - return; - } - if ( - explicitRef && - params.target.serviceAccount !== undefined && - !coerceSecretRef(params.target.serviceAccount, params.defaults) - ) { - pushWarning(params.context, { - code: "SECRETS_REF_OVERRIDES_PLAINTEXT", - path: params.path, - message: `${params.path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`, - }); - } - pushAssignment(params.context, { - ref, - path: `${params.path}.serviceAccount`, - expected: "string-or-object", - apply: (value) => { - params.target.serviceAccount = value; - }, - }); -} - -function collectGoogleChatAssignments(params: { - googleChat: GoogleChatAccountLike; - defaults: SecretDefaults | undefined; - context: ResolverContext; -}): void { - const googleChatRecord = params.googleChat as Record; - const surface = resolveChannelAccountSurface(googleChatRecord); - const topLevelServiceAccountActive = !surface.channelEnabled - ? false - : !surface.hasExplicitAccounts - ? true - : surface.accounts.some( - ({ account, enabled }) => - enabled && - !hasOwnProperty(account, "serviceAccount") && - !hasOwnProperty(account, "serviceAccountRef"), - ); - collectGoogleChatAccountAssignment({ - target: params.googleChat, - path: "channels.googlechat", - defaults: params.defaults, - context: params.context, - active: topLevelServiceAccountActive, - inactiveReason: "no enabled account inherits this top-level Google Chat serviceAccount.", - }); - if (!surface.hasExplicitAccounts) { - return; - } - for (const { accountId, account, enabled } of surface.accounts) { - if ( - !hasOwnProperty(account, "serviceAccount") && - !hasOwnProperty(account, "serviceAccountRef") - ) { - continue; - } - collectGoogleChatAccountAssignment({ - target: account as GoogleChatAccountLike, - path: `channels.googlechat.accounts.${accountId}`, - defaults: params.defaults, - context: params.context, - active: enabled, - inactiveReason: "Google Chat account is disabled.", - }); - } -} - export function collectChannelConfigAssignments(params: { config: OpenClawConfig; defaults: SecretDefaults | undefined; context: ResolverContext; }): void { - const googleChat = getChannelRecord(params.config, "googlechat") as - | GoogleChatAccountLike - | undefined; - if (googleChat) { - collectGoogleChatAssignments({ - googleChat, - defaults: params.defaults, - context: params.context, - }); - } - collectTelegramAssignments(params); - collectSlackAssignments(params); - collectDiscordAssignments(params); collectIrcAssignments(params); collectBlueBubblesAssignments(params); - collectMattermostAssignments(params); - collectMatrixAssignments(params); collectMSTeamsAssignments(params); collectNextcloudTalkAssignments(params); - collectFeishuAssignments(params); - collectZaloAssignments(params); + for (const surface of listChannelRuntimeConfigCollectorSurfaces()) { + surface.collectRuntimeConfigAssignments?.(params); + } } diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 2f7aa33648b..5e2b7294474 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -1,9 +1,20 @@ +import { getBundledChannelContractSurfaces } from "../channels/plugins/contract-surfaces.js"; import type { SecretTargetRegistryEntry } from "./target-registry-types.js"; +type ChannelSecretTargetRegistrySurface = { + secretTargetRegistryEntries?: readonly SecretTargetRegistryEntry[]; +}; + const SECRET_INPUT_SHAPE = "secret_input"; // pragma: allowlist secret const SIBLING_REF_SHAPE = "sibling_ref"; // pragma: allowlist secret -const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ +function listChannelSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] { + return (getBundledChannelContractSurfaces() as ChannelSecretTargetRegistrySurface[]).flatMap( + (surface) => surface.secretTargetRegistryEntries ?? [], + ); +} + +const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ { id: "auth-profiles.api_key.key", targetType: "auth-profiles.api_key.key", @@ -74,166 +85,6 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInConfigure: true, includeInAudit: true, }, - { - id: "channels.discord.accounts.*.pluralkit.token", - targetType: "channels.discord.accounts.*.pluralkit.token", - configFile: "openclaw.json", - pathPattern: "channels.discord.accounts.*.pluralkit.token", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.discord.accounts.*.token", - targetType: "channels.discord.accounts.*.token", - configFile: "openclaw.json", - pathPattern: "channels.discord.accounts.*.token", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.discord.accounts.*.voice.tts.providers.*.apiKey", - targetType: "channels.discord.accounts.*.voice.tts.providers.*.apiKey", - configFile: "openclaw.json", - pathPattern: "channels.discord.accounts.*.voice.tts.providers.*.apiKey", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - providerIdPathSegmentIndex: 6, - }, - { - id: "channels.discord.pluralkit.token", - targetType: "channels.discord.pluralkit.token", - configFile: "openclaw.json", - pathPattern: "channels.discord.pluralkit.token", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.discord.token", - targetType: "channels.discord.token", - configFile: "openclaw.json", - pathPattern: "channels.discord.token", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.discord.voice.tts.providers.*.apiKey", - targetType: "channels.discord.voice.tts.providers.*.apiKey", - configFile: "openclaw.json", - pathPattern: "channels.discord.voice.tts.providers.*.apiKey", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - providerIdPathSegmentIndex: 4, - }, - { - id: "channels.feishu.accounts.*.appSecret", - targetType: "channels.feishu.accounts.*.appSecret", - configFile: "openclaw.json", - pathPattern: "channels.feishu.accounts.*.appSecret", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.feishu.accounts.*.encryptKey", - targetType: "channels.feishu.accounts.*.encryptKey", - configFile: "openclaw.json", - pathPattern: "channels.feishu.accounts.*.encryptKey", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.feishu.accounts.*.verificationToken", - targetType: "channels.feishu.accounts.*.verificationToken", - configFile: "openclaw.json", - pathPattern: "channels.feishu.accounts.*.verificationToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.feishu.appSecret", - targetType: "channels.feishu.appSecret", - configFile: "openclaw.json", - pathPattern: "channels.feishu.appSecret", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.feishu.encryptKey", - targetType: "channels.feishu.encryptKey", - configFile: "openclaw.json", - pathPattern: "channels.feishu.encryptKey", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.feishu.verificationToken", - targetType: "channels.feishu.verificationToken", - configFile: "openclaw.json", - pathPattern: "channels.feishu.verificationToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.googlechat.accounts.*.serviceAccount", - targetType: "channels.googlechat.serviceAccount", - targetTypeAliases: ["channels.googlechat.accounts.*.serviceAccount"], - configFile: "openclaw.json", - pathPattern: "channels.googlechat.accounts.*.serviceAccount", - refPathPattern: "channels.googlechat.accounts.*.serviceAccountRef", - secretShape: SIBLING_REF_SHAPE, - expectedResolvedValue: "string-or-object", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - accountIdPathSegmentIndex: 3, - }, - { - id: "channels.googlechat.serviceAccount", - targetType: "channels.googlechat.serviceAccount", - configFile: "openclaw.json", - pathPattern: "channels.googlechat.serviceAccount", - refPathPattern: "channels.googlechat.serviceAccountRef", - secretShape: SIBLING_REF_SHAPE, - expectedResolvedValue: "string-or-object", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, { id: "channels.irc.accounts.*.nickserv.password", targetType: "channels.irc.accounts.*.nickserv.password", @@ -278,72 +129,6 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInConfigure: true, includeInAudit: true, }, - { - id: "channels.mattermost.accounts.*.botToken", - targetType: "channels.mattermost.accounts.*.botToken", - configFile: "openclaw.json", - pathPattern: "channels.mattermost.accounts.*.botToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.mattermost.botToken", - targetType: "channels.mattermost.botToken", - configFile: "openclaw.json", - pathPattern: "channels.mattermost.botToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.matrix.accounts.*.accessToken", - targetType: "channels.matrix.accounts.*.accessToken", - configFile: "openclaw.json", - pathPattern: "channels.matrix.accounts.*.accessToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.matrix.accounts.*.password", - targetType: "channels.matrix.accounts.*.password", - configFile: "openclaw.json", - pathPattern: "channels.matrix.accounts.*.password", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.matrix.accessToken", - targetType: "channels.matrix.accessToken", - configFile: "openclaw.json", - pathPattern: "channels.matrix.accessToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.matrix.password", - targetType: "channels.matrix.password", - configFile: "openclaw.json", - pathPattern: "channels.matrix.password", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, { id: "channels.msteams.appPassword", targetType: "channels.msteams.appPassword", @@ -399,182 +184,6 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInConfigure: true, includeInAudit: true, }, - { - id: "channels.slack.accounts.*.appToken", - targetType: "channels.slack.accounts.*.appToken", - configFile: "openclaw.json", - pathPattern: "channels.slack.accounts.*.appToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.slack.accounts.*.botToken", - targetType: "channels.slack.accounts.*.botToken", - configFile: "openclaw.json", - pathPattern: "channels.slack.accounts.*.botToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.slack.accounts.*.signingSecret", - targetType: "channels.slack.accounts.*.signingSecret", - configFile: "openclaw.json", - pathPattern: "channels.slack.accounts.*.signingSecret", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.slack.accounts.*.userToken", - targetType: "channels.slack.accounts.*.userToken", - configFile: "openclaw.json", - pathPattern: "channels.slack.accounts.*.userToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.slack.appToken", - targetType: "channels.slack.appToken", - configFile: "openclaw.json", - pathPattern: "channels.slack.appToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.slack.botToken", - targetType: "channels.slack.botToken", - configFile: "openclaw.json", - pathPattern: "channels.slack.botToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.slack.signingSecret", - targetType: "channels.slack.signingSecret", - configFile: "openclaw.json", - pathPattern: "channels.slack.signingSecret", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.slack.userToken", - targetType: "channels.slack.userToken", - configFile: "openclaw.json", - pathPattern: "channels.slack.userToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.telegram.accounts.*.botToken", - targetType: "channels.telegram.accounts.*.botToken", - configFile: "openclaw.json", - pathPattern: "channels.telegram.accounts.*.botToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.telegram.accounts.*.webhookSecret", - targetType: "channels.telegram.accounts.*.webhookSecret", - configFile: "openclaw.json", - pathPattern: "channels.telegram.accounts.*.webhookSecret", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.telegram.botToken", - targetType: "channels.telegram.botToken", - configFile: "openclaw.json", - pathPattern: "channels.telegram.botToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.telegram.webhookSecret", - targetType: "channels.telegram.webhookSecret", - configFile: "openclaw.json", - pathPattern: "channels.telegram.webhookSecret", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.zalo.accounts.*.botToken", - targetType: "channels.zalo.accounts.*.botToken", - configFile: "openclaw.json", - pathPattern: "channels.zalo.accounts.*.botToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.zalo.accounts.*.webhookSecret", - targetType: "channels.zalo.accounts.*.webhookSecret", - configFile: "openclaw.json", - pathPattern: "channels.zalo.accounts.*.webhookSecret", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.zalo.botToken", - targetType: "channels.zalo.botToken", - configFile: "openclaw.json", - pathPattern: "channels.zalo.botToken", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, - { - id: "channels.zalo.webhookSecret", - targetType: "channels.zalo.webhookSecret", - configFile: "openclaw.json", - pathPattern: "channels.zalo.webhookSecret", - secretShape: SECRET_INPUT_SHAPE, - expectedResolvedValue: "string", - includeInPlan: true, - includeInConfigure: true, - includeInAudit: true, - }, { id: "cron.webhookToken", targetType: "cron.webhookToken", @@ -947,4 +556,9 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ }, ]; +const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ + ...CORE_SECRET_TARGET_REGISTRY, + ...listChannelSecretTargetRegistryEntries(), +]; + export { SECRET_TARGET_REGISTRY }; diff --git a/src/secrets/unsupported-surface-policy.ts b/src/secrets/unsupported-surface-policy.ts index dc7143ae830..8099211edb9 100644 --- a/src/secrets/unsupported-surface-policy.ts +++ b/src/secrets/unsupported-surface-policy.ts @@ -1,15 +1,34 @@ +import { getBundledChannelContractSurfaces } from "../channels/plugins/contract-surfaces.js"; import { isRecord } from "../utils.js"; -export const UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [ +type ChannelUnsupportedSecretRefSurface = { + unsupportedSecretRefSurfacePatterns?: readonly string[]; + collectUnsupportedSecretRefConfigCandidates?: ( + raw: unknown, + ) => UnsupportedSecretRefConfigCandidate[]; +}; + +const CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [ "commands.ownerDisplaySecret", "hooks.token", "hooks.gmail.pushToken", "hooks.mappings[].sessionKey", "auth-profiles.oauth.*", - "channels.discord.threadBindings.webhookToken", - "channels.discord.accounts.*.threadBindings.webhookToken", - "channels.whatsapp.creds.json", - "channels.whatsapp.accounts.*.creds.json", +] as const; + +function listChannelUnsupportedSecretRefSurfaces(): ChannelUnsupportedSecretRefSurface[] { + return getBundledChannelContractSurfaces() as ChannelUnsupportedSecretRefSurface[]; +} + +function collectChannelUnsupportedSecretRefSurfacePatterns(): string[] { + return listChannelUnsupportedSecretRefSurfaces().flatMap( + (surface) => surface.unsupportedSecretRefSurfacePatterns ?? [], + ); +} + +export const UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [ + ...CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS, + ...collectChannelUnsupportedSecretRefSurfacePatterns(), ] as const; export type UnsupportedSecretRefConfigCandidate = { @@ -60,68 +79,12 @@ export function collectUnsupportedSecretRefConfigCandidates( } } - const channels = isRecord(raw.channels) ? raw.channels : null; - if (!channels) { - return candidates; - } - - const discord = isRecord(channels.discord) ? channels.discord : null; - if (discord) { - const threadBindings = isRecord(discord.threadBindings) ? discord.threadBindings : null; - if (threadBindings) { - candidates.push({ - path: "channels.discord.threadBindings.webhookToken", - value: threadBindings.webhookToken, - }); - } - const accounts = isRecord(discord.accounts) ? discord.accounts : null; - if (accounts) { - for (const [accountId, account] of Object.entries(accounts)) { - if (!isRecord(account)) { - continue; - } - const accountThreadBindings = isRecord(account.threadBindings) - ? account.threadBindings - : null; - if (!accountThreadBindings) { - continue; - } - candidates.push({ - path: `channels.discord.accounts.${accountId}.threadBindings.webhookToken`, - value: accountThreadBindings.webhookToken, - }); - } - } - } - - const whatsapp = isRecord(channels.whatsapp) ? channels.whatsapp : null; - if (!whatsapp) { - return candidates; - } - - const creds = isRecord(whatsapp.creds) ? whatsapp.creds : null; - if (creds) { - candidates.push({ - path: "channels.whatsapp.creds.json", - value: creds.json, - }); - } - const accounts = isRecord(whatsapp.accounts) ? whatsapp.accounts : null; - if (!accounts) { - return candidates; - } - for (const [accountId, account] of Object.entries(accounts)) { - if (!isRecord(account)) { + for (const surface of listChannelUnsupportedSecretRefSurfaces()) { + const channelCandidates = surface.collectUnsupportedSecretRefConfigCandidates?.(raw); + if (!channelCandidates?.length) { continue; } - const accountCreds = isRecord(account.creds) ? account.creds : null; - if (!accountCreds) { - continue; - } - candidates.push({ - path: `channels.whatsapp.accounts.${accountId}.creds.json`, - value: accountCreds.json, - }); + candidates.push(...channelCandidates); } return candidates; diff --git a/src/security/audit-channel.discord.runtime.ts b/src/security/audit-channel.discord.runtime.ts deleted file mode 100644 index cc30011c349..00000000000 --- a/src/security/audit-channel.discord.runtime.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { isDiscordMutableAllowEntry } from "./mutable-allowlist-detectors.js"; - -export const auditChannelDiscordRuntime = { - isDiscordMutableAllowEntry, -}; diff --git a/src/security/audit-channel.telegram.runtime.ts b/src/security/audit-channel.telegram.runtime.ts deleted file mode 100644 index ea399e8aa64..00000000000 --- a/src/security/audit-channel.telegram.runtime.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - isNumericTelegramUserId, - normalizeTelegramAllowFromEntry, -} from "../channels/read-only-account-inspect.telegram.js"; - -export const auditChannelTelegramRuntime = { - isNumericTelegramUserId, - normalizeTelegramAllowFromEntry, -}; diff --git a/src/security/audit-channel.zalouser.runtime.ts b/src/security/audit-channel.zalouser.runtime.ts deleted file mode 100644 index 1fb39e030a1..00000000000 --- a/src/security/audit-channel.zalouser.runtime.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { isZalouserMutableGroupEntry } from "./mutable-allowlist-detectors.js"; - -export const auditChannelZalouserRuntime = { - isZalouserMutableGroupEntry, -}; diff --git a/src/security/audit.ts b/src/security/audit.ts index 465aa703c1a..faaea0f314e 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -203,46 +203,6 @@ function hasNonEmptyString(value: unknown): boolean { return typeof value === "string" && value.trim().length > 0; } -function isFeishuDocToolEnabled(cfg: OpenClawConfig): boolean { - const channels = asRecord(cfg.channels); - const feishu = asRecord(channels?.feishu); - if (!feishu || feishu.enabled === false) { - return false; - } - - const baseTools = asRecord(feishu.tools); - const baseDocEnabled = baseTools?.doc !== false; - const baseAppId = hasNonEmptyString(feishu.appId); - const baseAppSecret = hasConfiguredSecretInput(feishu.appSecret, cfg.secrets?.defaults); - const baseConfigured = baseAppId && baseAppSecret; - - const accounts = asRecord(feishu.accounts); - if (!accounts || Object.keys(accounts).length === 0) { - return baseDocEnabled && baseConfigured; - } - - for (const accountValue of Object.values(accounts)) { - const account = asRecord(accountValue) ?? {}; - if (account.enabled === false) { - continue; - } - const accountTools = asRecord(account.tools); - const effectiveTools = accountTools ?? baseTools; - const docEnabled = effectiveTools?.doc !== false; - if (!docEnabled) { - continue; - } - const accountConfigured = - (hasNonEmptyString(account.appId) || baseAppId) && - (hasConfiguredSecretInput(account.appSecret, cfg.secrets?.defaults) || baseAppSecret); - if (accountConfigured) { - return true; - } - } - - return false; -} - async function collectFilesystemFindings(params: { stateDir: string; configPath: string; @@ -612,18 +572,6 @@ function collectGatewayConfigFindings( }); } - if (isFeishuDocToolEnabled(cfg)) { - findings.push({ - checkId: "channels.feishu.doc_owner_open_id", - severity: "warn", - title: "Feishu doc create can grant requester permissions", - detail: - 'channels.feishu tools include "doc"; feishu_doc action "create" can grant document access to the trusted requesting Feishu user.', - remediation: - "Disable channels.feishu.tools.doc when not needed, and restrict tool access for untrusted prompts.", - }); - } - const enabledDangerousFlags = collectEnabledInsecureOrDangerousFlags(cfg); if (enabledDangerousFlags.length > 0) { findings.push({ diff --git a/src/security/fix.ts b/src/security/fix.ts index d0c86e528cf..5a057cd4fba 100644 --- a/src/security/fix.ts +++ b/src/security/fix.ts @@ -1,13 +1,14 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listBundledChannelPlugins } from "../channels/plugins/bundled.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { createConfigIO } from "../config/config.js"; import { collectIncludePathsRecursive } from "../config/includes-scan.js"; import { resolveConfigPath, resolveOAuthDir, resolveStateDir } from "../config/paths.js"; -import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { runExec } from "../process/exec.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { createIcaclsResetCommand, formatIcaclsResetCommand, type ExecFn } from "./windows-acl.js"; export type SecurityFixChmodAction = { @@ -187,7 +188,6 @@ function setGroupPolicyAllowlist(params: { cfg: OpenClawConfig; channel: string; changes: string[]; - policyFlips: Set; }): void { if (!params.cfg.channels) { return; @@ -203,7 +203,6 @@ function setGroupPolicyAllowlist(params: { if (topPolicy === "open") { section.groupPolicy = "allowlist"; params.changes.push(`channels.${params.channel}.groupPolicy=open -> allowlist`); - params.policyFlips.add(`channels.${params.channel}.`); } const accounts = section.accounts; @@ -223,83 +222,60 @@ function setGroupPolicyAllowlist(params: { params.changes.push( `channels.${params.channel}.accounts.${accountId}.groupPolicy=open -> allowlist`, ); - params.policyFlips.add(`channels.${params.channel}.accounts.${accountId}.`); } } } -function setWhatsAppGroupAllowFromFromStore(params: { - cfg: OpenClawConfig; - storeAllowFrom: string[]; - changes: string[]; - policyFlips: Set; -}): void { - const section = params.cfg.channels?.whatsapp as Record | undefined; - if (!section || typeof section !== "object") { - return; - } - if (params.storeAllowFrom.length === 0) { - return; - } - - const maybeApply = (prefix: string, obj: Record) => { - if (!params.policyFlips.has(prefix)) { - return; - } - const allowFrom = Array.isArray(obj.allowFrom) ? obj.allowFrom : []; - const groupAllowFrom = Array.isArray(obj.groupAllowFrom) ? obj.groupAllowFrom : []; - if (allowFrom.length > 0) { - return; - } - if (groupAllowFrom.length > 0) { - return; - } - obj.groupAllowFrom = params.storeAllowFrom; - params.changes.push(`${prefix}groupAllowFrom=pairing-store`); - }; - - maybeApply("channels.whatsapp.", section); - - const accounts = section.accounts; - if (!accounts || typeof accounts !== "object") { - return; - } - for (const [accountId, accountValue] of Object.entries(accounts)) { - if (!accountValue || typeof accountValue !== "object") { - continue; - } - const account = accountValue as Record; - maybeApply(`channels.whatsapp.accounts.${accountId}.`, account); - } -} - function applyConfigFixes(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv }): { cfg: OpenClawConfig; changes: string[]; - policyFlips: Set; } { const next = structuredClone(params.cfg ?? {}); const changes: string[] = []; - const policyFlips = new Set(); if (next.logging?.redactSensitive === "off") { next.logging = { ...next.logging, redactSensitive: "tools" }; changes.push('logging.redactSensitive=off -> "tools"'); } - for (const channel of [ - "telegram", - "whatsapp", - "discord", - "signal", - "imessage", - "slack", - "msteams", - ]) { - setGroupPolicyAllowlist({ cfg: next, channel, changes, policyFlips }); + for (const channel of Object.keys(next.channels ?? {})) { + setGroupPolicyAllowlist({ cfg: next, channel, changes }); } - return { cfg: next, changes, policyFlips }; + return { cfg: next, changes }; +} + +async function collectChannelSecurityConfigFixMutation(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}) { + let nextCfg = params.cfg; + const changes: string[] = []; + const collectPlugins = (): ChannelPlugin[] => { + try { + const pluginIds = Object.keys(params.cfg.channels ?? {}).filter(Boolean); + if (pluginIds.length === 0) { + return []; + } + const wanted = new Set(pluginIds); + return listBundledChannelPlugins().filter((plugin) => wanted.has(plugin.id)); + } catch { + return []; + } + }; + + for (const plugin of collectPlugins()) { + const mutation = await plugin.security?.applyConfigFixes?.({ + cfg: nextCfg, + env: params.env, + }); + if (!mutation || mutation.changes.length === 0) { + continue; + } + nextCfg = mutation.config; + changes.push(...mutation.changes); + } + return { cfg: nextCfg, changes }; } async function chmodCredentialsAndAgentState(params: { @@ -410,25 +386,15 @@ export async function fixSecurityFootguns(opts?: { let changes: string[] = []; if (snap.valid) { const fixed = applyConfigFixes({ cfg: snap.config, env }); - changes = fixed.changes; - - const whatsappStoreAllowFrom = await readChannelAllowFromStore( - "whatsapp", + const channelFixes = await collectChannelSecurityConfigFixMutation({ + cfg: fixed.cfg, env, - DEFAULT_ACCOUNT_ID, - ).catch(() => []); - if (whatsappStoreAllowFrom.length > 0) { - setWhatsAppGroupAllowFromFromStore({ - cfg: fixed.cfg, - storeAllowFrom: whatsappStoreAllowFrom, - changes, - policyFlips: fixed.policyFlips, - }); - } + }); + changes = [...fixed.changes, ...channelFixes.changes]; if (changes.length > 0) { try { - await io.writeConfigFile(fixed.cfg); + await io.writeConfigFile(channelFixes.cfg); configWritten = true; } catch (err) { errors.push(`writeConfigFile failed: ${String(err)}`); diff --git a/src/security/mutable-allowlist-detectors.ts b/src/security/mutable-allowlist-detectors.ts deleted file mode 100644 index d37e1a7cc9e..00000000000 --- a/src/security/mutable-allowlist-detectors.ts +++ /dev/null @@ -1,122 +0,0 @@ -export function isDiscordMutableAllowEntry(raw: string): boolean { - const text = raw.trim(); - if (!text || text === "*") { - return false; - } - - const maybeMentionId = text.replace(/^<@!?/, "").replace(/>$/, ""); - if (/^\d+$/.test(maybeMentionId)) { - return false; - } - - for (const prefix of ["discord:", "user:", "pk:"]) { - if (!text.startsWith(prefix)) { - continue; - } - return text.slice(prefix.length).trim().length === 0; - } - - return true; -} - -export function isSlackMutableAllowEntry(raw: string): boolean { - const text = raw.trim(); - if (!text || text === "*") { - return false; - } - - const mentionMatch = text.match(/^<@([A-Z0-9]+)>$/i); - if (mentionMatch && /^[A-Z0-9]{8,}$/i.test(mentionMatch[1] ?? "")) { - return false; - } - - const withoutPrefix = text.replace(/^(slack|user):/i, "").trim(); - if (/^[UWBCGDT][A-Z0-9]{2,}$/.test(withoutPrefix)) { - return false; - } - if (/^[A-Z0-9]{8,}$/i.test(withoutPrefix)) { - return false; - } - - return true; -} - -export function isGoogleChatMutableAllowEntry(raw: string): boolean { - const text = raw.trim(); - if (!text || text === "*") { - return false; - } - - const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim(); - if (!withoutPrefix) { - return false; - } - - const withoutUsers = withoutPrefix.replace(/^users\//i, ""); - return withoutUsers.includes("@"); -} - -export function isMSTeamsMutableAllowEntry(raw: string): boolean { - const text = raw.trim(); - if (!text || text === "*") { - return false; - } - - const withoutPrefix = text.replace(/^(msteams|user):/i, "").trim(); - return /\s/.test(withoutPrefix) || withoutPrefix.includes("@"); -} - -export function isMattermostMutableAllowEntry(raw: string): boolean { - const text = raw.trim(); - if (!text || text === "*") { - return false; - } - - const normalized = text - .replace(/^(mattermost|user):/i, "") - .replace(/^@/, "") - .trim() - .toLowerCase(); - - // Mattermost user IDs are stable 26-char lowercase/number tokens. - if (/^[a-z0-9]{26}$/.test(normalized)) { - return false; - } - - return true; -} - -export function isIrcMutableAllowEntry(raw: string): boolean { - const text = raw.trim().toLowerCase(); - if (!text || text === "*") { - return false; - } - - const normalized = text - .replace(/^irc:/, "") - .replace(/^user:/, "") - .trim(); - - return !normalized.includes("!") && !normalized.includes("@"); -} - -export function isZalouserMutableGroupEntry(raw: string): boolean { - const text = raw.trim(); - if (!text || text === "*") { - return false; - } - - const normalized = text - .replace(/^(zalouser|zlu):/i, "") - .replace(/^group:/i, "") - .trim(); - - if (!normalized) { - return false; - } - if (/^\d+$/.test(normalized)) { - return false; - } - - return !/^g-\S+$/i.test(normalized); -} diff --git a/src/sessions/session-key-utils.ts b/src/sessions/session-key-utils.ts index 940c22fb957..ee6dd6e7ec4 100644 --- a/src/sessions/session-key-utils.ts +++ b/src/sessions/session-key-utils.ts @@ -1,3 +1,5 @@ +import { getBundledChannelContractSurfaces } from "../channels/plugins/contract-surfaces.js"; + export type ParsedAgentSessionKey = { agentId: string; rest: string; @@ -16,6 +18,14 @@ export type RawSessionConversationRef = { prefix: string; }; +type LegacySessionChatTypeSurface = { + deriveLegacySessionChatType?: (sessionKey: string) => "direct" | "group" | "channel" | undefined; +}; + +function listLegacySessionChatTypeSurfaces(): LegacySessionChatTypeSurface[] { + return getBundledChannelContractSurfaces() as LegacySessionChatTypeSurface[]; +} + /** * Parse agent-scoped session keys in a canonical, case-insensitive way. * Returned values are normalized to lowercase for stable comparisons/routing. @@ -61,10 +71,11 @@ export function deriveSessionChatType(sessionKey: string | undefined | null): Se if (tokens.has("direct") || tokens.has("dm")) { return "direct"; } - // Legacy Discord keys can be shaped like: - // discord::guild-:channel- - if (/^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(scoped)) { - return "channel"; + for (const surface of listLegacySessionChatTypeSurfaces()) { + const derived = surface.deriveLegacySessionChatType?.(scoped); + if (derived) { + return derived; + } } return "unknown"; } diff --git a/src/utils.ts b/src/utils.ts index b7aa3d03696..7b5810f9e12 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolveOAuthDir } from "./config/paths.js"; -import { logVerbose, shouldLogVerbose } from "./globals.js"; import { resolveEffectiveHomeDir, resolveHomeRelativePath, @@ -66,16 +64,8 @@ export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -export type WebChannel = "web"; - -export function assertWebChannel(input: string): asserts input is WebChannel { - if (input !== "web") { - throw new Error("Web channel must be 'web'"); - } -} - export function normalizeE164(number: string): string { - const withoutPrefix = number.replace(/^whatsapp:/, "").trim(); + const withoutPrefix = number.replace(/^[a-z][a-z0-9-]*:/i, "").trim(); const digits = withoutPrefix.replace(/[^\d+]/g, ""); if (digits.startsWith("+")) { return `+${digits.slice(1)}`; @@ -83,146 +73,6 @@ export function normalizeE164(number: string): string { return `+${digits}`; } -/** - * "Self-chat mode" heuristic (single phone): the gateway is logged in as the owner's own WhatsApp account, - * and `channels.whatsapp.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the - * "bot" and the human are the same WhatsApp identity (e.g. auto read receipts, @mention JID triggers). - */ -export function isSelfChatMode( - selfE164: string | null | undefined, - allowFrom?: Array | null, -): boolean { - if (!selfE164) { - return false; - } - if (!Array.isArray(allowFrom) || allowFrom.length === 0) { - return false; - } - const normalizedSelf = normalizeE164(selfE164); - return allowFrom.some((n) => { - if (n === "*") { - return false; - } - try { - return normalizeE164(String(n)) === normalizedSelf; - } catch { - return false; - } - }); -} - -export function toWhatsappJid(number: string): string { - const withoutPrefix = number.replace(/^whatsapp:/, "").trim(); - if (withoutPrefix.includes("@")) { - return withoutPrefix; - } - const e164 = normalizeE164(withoutPrefix); - const digits = e164.replace(/\D/g, ""); - return `${digits}@s.whatsapp.net`; -} - -export type JidToE164Options = { - authDir?: string; - lidMappingDirs?: string[]; - logMissing?: boolean; -}; - -type LidLookup = { - getPNForLID?: (jid: string) => Promise; -}; - -function resolveLidMappingDirs(opts?: JidToE164Options): string[] { - const dirs = new Set(); - const addDir = (dir?: string | null) => { - if (!dir) { - return; - } - dirs.add(resolveUserPath(dir)); - }; - addDir(opts?.authDir); - for (const dir of opts?.lidMappingDirs ?? []) { - addDir(dir); - } - addDir(resolveOAuthDir()); - addDir(path.join(CONFIG_DIR, "credentials")); - return [...dirs]; -} - -function readLidReverseMapping(lid: string, opts?: JidToE164Options): string | null { - const mappingFilename = `lid-mapping-${lid}_reverse.json`; - const mappingDirs = resolveLidMappingDirs(opts); - for (const dir of mappingDirs) { - const mappingPath = path.join(dir, mappingFilename); - try { - const data = fs.readFileSync(mappingPath, "utf8"); - const phone = JSON.parse(data) as string | number | null; - if (phone === null || phone === undefined) { - continue; - } - return normalizeE164(String(phone)); - } catch { - // Try the next location. - } - } - return null; -} - -export function jidToE164(jid: string, opts?: JidToE164Options): string | null { - // Convert a WhatsApp JID (with optional device suffix, e.g. 1234:1@s.whatsapp.net) back to +1234. - const match = jid.match(/^(\d+)(?::\d+)?@(s\.whatsapp\.net|hosted)$/); - if (match) { - const digits = match[1]; - return `+${digits}`; - } - - // Support @lid format (WhatsApp Linked ID) - look up reverse mapping - const lidMatch = jid.match(/^(\d+)(?::\d+)?@(lid|hosted\.lid)$/); - if (lidMatch) { - const lid = lidMatch[1]; - const phone = readLidReverseMapping(lid, opts); - if (phone) { - return phone; - } - const shouldLog = opts?.logMissing ?? shouldLogVerbose(); - if (shouldLog) { - logVerbose(`LID mapping not found for ${lid}; skipping inbound message`); - } - } - - return null; -} - -export async function resolveJidToE164( - jid: string | null | undefined, - opts?: JidToE164Options & { lidLookup?: LidLookup }, -): Promise { - if (!jid) { - return null; - } - const direct = jidToE164(jid, opts); - if (direct) { - return direct; - } - if (!/(@lid|@hosted\.lid)$/.test(jid)) { - return null; - } - if (!opts?.lidLookup?.getPNForLID) { - return null; - } - try { - const pnJid = await opts.lidLookup.getPNForLID(jid); - if (!pnJid) { - return null; - } - return jidToE164(pnJid, opts); - } catch (err) { - if (shouldLogVerbose()) { - logVerbose(`LID mapping lookup failed for ${jid}: ${String(err)}`); - } - return null; - } -} - export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); }