From bfcfc17a8bacb52bf1a82617f96f3d5564241a96 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Mar 2026 20:07:24 +0000 Subject: [PATCH] refactor: tighten plugin sdk entry surface --- docs/plugins/building-plugins.md | 19 +- docs/plugins/sdk-migration.md | 3 +- extensions/anthropic/index.ts | 2 +- extensions/brave/index.ts | 2 +- extensions/byteplus/index.ts | 2 +- extensions/cloudflare-ai-gateway/index.ts | 2 +- extensions/diagnostics-otel/index.ts | 2 +- extensions/elevenlabs/index.ts | 2 +- extensions/fal/index.ts | 2 +- extensions/firecrawl/index.ts | 2 +- extensions/github-copilot/index.ts | 2 +- extensions/googlechat/index.ts | 3 +- extensions/googlechat/src/channel.ts | 486 +++++----- extensions/huggingface/index.ts | 2 +- extensions/kimi-coding/index.ts | 2 +- extensions/lobster/index.ts | 2 +- extensions/memory-core/index.ts | 2 +- extensions/microsoft/index.ts | 2 +- extensions/mistral/index.ts | 2 +- extensions/modelstudio/index.ts | 2 +- extensions/nvidia/index.ts | 2 +- extensions/ollama/index.ts | 2 +- extensions/opencode-go/index.ts | 2 +- extensions/opencode/index.ts | 2 +- extensions/perplexity/index.ts | 2 +- extensions/qianfan/index.ts | 2 +- extensions/sglang/index.ts | 2 +- extensions/synthetic/index.ts | 2 +- extensions/tavily/index.ts | 2 +- extensions/telegram/src/channel.ts | 855 +++++++++--------- extensions/together/index.ts | 2 +- extensions/venice/index.ts | 2 +- extensions/vercel-ai-gateway/index.ts | 2 +- extensions/vllm/index.ts | 2 +- extensions/volcengine/index.ts | 2 +- extensions/xiaomi/index.ts | 2 +- extensions/zai/index.ts | 2 +- src/plugin-sdk/copilot-proxy.ts | 2 +- src/plugin-sdk/core.ts | 224 +++-- src/plugin-sdk/diffs.ts | 2 +- src/plugin-sdk/llm-task.ts | 2 +- src/plugin-sdk/lobster.ts | 2 +- src/plugin-sdk/memory-lancedb.ts | 2 +- src/plugin-sdk/open-prose.ts | 2 +- src/plugin-sdk/phone-control.ts | 2 +- .../plugin-entry-guardrails.test.ts | 33 + src/plugin-sdk/subpaths.test.ts | 6 + src/plugin-sdk/thread-ownership.ts | 2 +- src/plugin-sdk/voice-call.ts | 2 +- 49 files changed, 937 insertions(+), 774 deletions(-) create mode 100644 src/plugin-sdk/plugin-entry-guardrails.test.ts diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md index 121b673f5c6..fa298294943 100644 --- a/docs/plugins/building-plugins.md +++ b/docs/plugins/building-plugins.md @@ -128,7 +128,7 @@ my-plugin/ **Provider plugin:** ```typescript - import { definePluginEntry } from "openclaw/plugin-sdk/core"; + import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; export default definePluginEntry({ id: "my-provider", @@ -144,7 +144,7 @@ my-plugin/ **Multi-capability plugin** (provider + tool): ```typescript - import { definePluginEntry } from "openclaw/plugin-sdk/core"; + import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; export default definePluginEntry({ id: "my-plugin", @@ -157,8 +157,14 @@ my-plugin/ }); ``` - Use `defineChannelPluginEntry` for channel plugins and `definePluginEntry` - for everything else. A single plugin can register as many capabilities as needed. + Use `defineChannelPluginEntry` from `plugin-sdk/core` for channel plugins + and `definePluginEntry` from `plugin-sdk/plugin-entry` for everything else. + A single plugin can register as many capabilities as needed. + + For chat-style channels, `plugin-sdk/core` also exposes + `createChatChannelPlugin(...)` so you can compose common DM security, + text pairing, reply threading, and attached outbound send results without + wiring each adapter separately. @@ -173,7 +179,7 @@ my-plugin/ ```typescript // Correct: focused subpaths - import { definePluginEntry } from "openclaw/plugin-sdk/core"; + import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth"; @@ -187,7 +193,8 @@ my-plugin/ | Subpath | Purpose | | --- | --- | - | `plugin-sdk/core` | Plugin entry definitions and base types | + | `plugin-sdk/plugin-entry` | Canonical `definePluginEntry` helper + provider/plugin entry types | + | `plugin-sdk/core` | Channel entry helpers, channel builders, and shared base types | | `plugin-sdk/channel-setup` | Setup wizard adapters | | `plugin-sdk/channel-pairing` | DM pairing primitives | | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 52501f5b9c7..b0cd34fda21 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -115,7 +115,8 @@ is a small, self-contained module with a clear purpose and documented contract. | Import path | Purpose | Key exports | | --- | --- | --- | - | `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` | + | `plugin-sdk/plugin-entry` | Canonical plugin entry helper | `definePluginEntry` | + | `plugin-sdk/core` | Channel entry definitions, channel builders, base types | `defineChannelPluginEntry`, `createChatChannelPlugin` | | `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` | | `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` | | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` | diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index 78f5bf3c17a..4a499ced761 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -5,7 +5,7 @@ import { type ProviderAuthContext, type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/plugin-entry"; import { CLAUDE_CLI_PROFILE_ID, applyAuthProfileConfig, diff --git a/extensions/brave/index.ts b/extensions/brave/index.ts index 7ded10c9361..c02c1510e8d 100644 --- a/extensions/brave/index.ts +++ b/extensions/brave/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js"; export default definePluginEntry({ diff --git a/extensions/byteplus/index.ts b/extensions/byteplus/index.ts index a89cc87f531..17bf790c616 100644 --- a/extensions/byteplus/index.ts +++ b/extensions/byteplus/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard"; import { buildBytePlusCodingProvider, buildBytePlusProvider } from "./provider-catalog.js"; diff --git a/extensions/cloudflare-ai-gateway/index.ts b/extensions/cloudflare-ai-gateway/index.ts index a0307d9d524..193d7d412d3 100644 --- a/extensions/cloudflare-ai-gateway/index.ts +++ b/extensions/cloudflare-ai-gateway/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { applyAuthProfileConfig, buildApiKeyCredential, diff --git a/extensions/diagnostics-otel/index.ts b/extensions/diagnostics-otel/index.ts index 15b6aee404e..b190269e2e1 100644 --- a/extensions/diagnostics-otel/index.ts +++ b/extensions/diagnostics-otel/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createDiagnosticsOtelService } from "./src/service.js"; export default definePluginEntry({ diff --git a/extensions/elevenlabs/index.ts b/extensions/elevenlabs/index.ts index 4d32eb4c532..b77c523d6a8 100644 --- a/extensions/elevenlabs/index.ts +++ b/extensions/elevenlabs/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { buildElevenLabsSpeechProvider } from "openclaw/plugin-sdk/speech"; export default definePluginEntry({ diff --git a/extensions/fal/index.ts b/extensions/fal/index.ts index 8c1db68f391..b26468879d3 100644 --- a/extensions/fal/index.ts +++ b/extensions/fal/index.ts @@ -1,5 +1,5 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; import { buildFalImageGenerationProvider } from "openclaw/plugin-sdk/image-generation"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js"; diff --git a/extensions/firecrawl/index.ts b/extensions/firecrawl/index.ts index aa6e41070be..5d72eeb583a 100644 --- a/extensions/firecrawl/index.ts +++ b/extensions/firecrawl/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/core"; +import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry"; import { createFirecrawlScrapeTool } from "./src/firecrawl-scrape-tool.js"; import { createFirecrawlWebSearchProvider } from "./src/firecrawl-search-provider.js"; import { createFirecrawlSearchTool } from "./src/firecrawl-search-tool.js"; diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 1908b332ca7..2b0b54d2494 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -1,5 +1,5 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/agent-runtime"; -import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/core"; +import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry"; import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth"; import { githubCopilotLoginCommand } from "openclaw/plugin-sdk/provider-auth-login"; import { PROVIDER_ID, resolveCopilotForwardCompatModel } from "./models.js"; diff --git a/extensions/googlechat/index.ts b/extensions/googlechat/index.ts index 850bd4b6a87..a9bf2fe10a6 100644 --- a/extensions/googlechat/index.ts +++ b/extensions/googlechat/index.ts @@ -1,3 +1,4 @@ +import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { googlechatPlugin } from "./src/channel.js"; import { setGoogleChatRuntime } from "./src/runtime.js"; @@ -9,6 +10,6 @@ export default defineChannelPluginEntry({ id: "googlechat", name: "Google Chat", description: "OpenClaw Google Chat channel plugin", - plugin: googlechatPlugin, + plugin: googlechatPlugin as ChannelPlugin, setRuntime: setGoogleChatRuntime, }); diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index e8917d13c04..c4e76f1561d 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -1,17 +1,12 @@ import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; -import { - createScopedChannelConfigAdapter, - createScopedDmSecurityResolver, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { createTextPairingAdapter } from "openclaw/plugin-sdk/channel-pairing"; +import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; import { composeWarningCollectors, createAllowlistProviderGroupPolicyWarningCollector, createConditionalWarningCollector, createAllowlistProviderOpenWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; -import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; -import { createTopLevelChannelReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; +import { createChatChannelPlugin } from "openclaw/plugin-sdk/core"; import { createChannelDirectoryAdapter, listResolvedDirectoryGroupEntriesFromMapKeys, @@ -30,7 +25,6 @@ import { resolveChannelMediaMaxBytes, runPassiveAccountLifecycle, type ChannelMessageActionAdapter, - type ChannelPlugin, type ChannelStatusIssue, type OpenClawConfig, } from "../runtime-api.js"; @@ -92,14 +86,6 @@ const googleChatConfigAdapter = createScopedChannelConfigAdapter account.config.defaultTo, }); -const resolveGoogleChatDmPolicy = createScopedDmSecurityResolver({ - channelKey: "googlechat", - resolvePolicy: (account) => account.config.dm?.policy, - resolveAllowFrom: (account) => account.config.dm?.allowFrom, - allowFromPathSuffix: "dm.", - normalizeEntry: (raw) => formatAllowFromEntry(raw), -}); - const googlechatActions: ChannelMessageActionAdapter = { describeMessageTool: (ctx) => googlechatMessageActions.describeMessageTool?.(ctx) ?? null, extractToolSend: (ctx) => googlechatMessageActions.extractToolSend?.(ctx) ?? null, @@ -135,138 +121,258 @@ const collectGoogleChatSecurityWarnings = composeWarningCollectors<{ ), ); -export const googlechatPlugin: ChannelPlugin = { - id: "googlechat", - meta: { ...meta }, - setup: googlechatSetupAdapter, - setupWizard: googlechatSetupWizard, - pairing: createTextPairingAdapter({ - idLabel: "googlechatUserId", - message: PAIRING_APPROVED_MESSAGE, - normalizeAllowEntry: (entry) => formatAllowFromEntry(entry), - notify: async ({ cfg, id, message }) => { - const account = resolveGoogleChatAccount({ cfg: cfg }); - if (account.credentialSource === "none") { - return; - } - const user = normalizeGoogleChatTarget(id) ?? id; - const target = isGoogleChatUserTarget(user) ? user : `users/${user}`; - const space = await resolveGoogleChatOutboundSpace({ account, target }); - const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime(); - await sendGoogleChatMessage({ - account, - space, - text: message, - }); +export const googlechatPlugin = createChatChannelPlugin({ + base: { + id: "googlechat", + meta: { ...meta }, + setup: googlechatSetupAdapter, + setupWizard: googlechatSetupWizard, + capabilities: { + chatTypes: ["direct", "group", "thread"], + reactions: true, + threads: true, + media: true, + nativeCommands: false, + blockStreaming: true, }, - }), - capabilities: { - chatTypes: ["direct", "group", "thread"], - reactions: true, - threads: true, - media: true, - nativeCommands: false, - blockStreaming: true, - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.googlechat"] }, - configSchema: buildChannelConfigSchema(GoogleChatConfigSchema), - config: { - ...googleChatConfigAdapter, - isConfigured: (account) => account.credentialSource !== "none", - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.credentialSource !== "none", - credentialSource: account.credentialSource, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.googlechat"] }, + configSchema: buildChannelConfigSchema(GoogleChatConfigSchema), + config: { + ...googleChatConfigAdapter, + isConfigured: (account) => account.credentialSource !== "none", + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.credentialSource !== "none", + credentialSource: account.credentialSource, + }), + }, + groups: { + resolveRequireMention: resolveGoogleChatGroupRequireMention, + }, + messaging: { + normalizeTarget: normalizeGoogleChatTarget, + targetResolver: { + looksLikeId: (raw, normalized) => { + const value = normalized ?? raw.trim(); + return isGoogleChatSpaceTarget(value) || isGoogleChatUserTarget(value); + }, + hint: "", + }, + }, + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => + listResolvedDirectoryUserEntriesFromAllowFrom({ + ...params, + resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }), + resolveAllowFrom: (account) => account.config.dm?.allowFrom, + normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry, + }), + listGroups: async (params) => + listResolvedDirectoryGroupEntriesFromMapKeys({ + ...params, + resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }), + resolveGroups: (account) => account.config.groups, + }), }), + resolver: { + resolveTargets: async ({ inputs, kind }) => { + const resolved = inputs.map((input) => { + const normalized = normalizeGoogleChatTarget(input); + if (!normalized) { + return { input, resolved: false, note: "empty target" }; + } + if (kind === "user" && isGoogleChatUserTarget(normalized)) { + return { input, resolved: true, id: normalized }; + } + if (kind === "group" && isGoogleChatSpaceTarget(normalized)) { + return { input, resolved: true, id: normalized }; + } + return { + input, + resolved: false, + note: "use spaces/{space} or users/{user}", + }; + }); + return resolved; + }, + }, + actions: googlechatActions, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts): ChannelStatusIssue[] => + accounts.flatMap((entry) => { + const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID); + const enabled = entry.enabled !== false; + const configured = entry.configured === true; + if (!enabled || !configured) { + return []; + } + const issues: ChannelStatusIssue[] = []; + if (!entry.audience) { + issues.push({ + channel: "googlechat", + accountId, + kind: "config", + message: "Google Chat audience is missing (set channels.googlechat.audience).", + fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.", + }); + } + if (!entry.audienceType) { + issues.push({ + channel: "googlechat", + accountId, + kind: "config", + message: "Google Chat audienceType is missing (app-url or project-number).", + fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.", + }); + } + return issues; + }), + buildChannelSummary: ({ snapshot }) => + buildPassiveProbedChannelStatusSummary(snapshot, { + credentialSource: snapshot.credentialSource ?? "none", + audienceType: snapshot.audienceType ?? null, + audience: snapshot.audience ?? null, + webhookPath: snapshot.webhookPath ?? null, + webhookUrl: snapshot.webhookUrl ?? null, + }), + probeAccount: async ({ account }) => + (await loadGoogleChatChannelRuntime()).probeGoogleChat(account), + buildAccountSnapshot: ({ account, runtime, probe }) => { + const base = buildComputedAccountStatusSnapshot({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.credentialSource !== "none", + runtime, + probe, + }); + return { + ...base, + credentialSource: account.credentialSource, + audienceType: account.config.audienceType, + audience: account.config.audience, + webhookPath: account.config.webhookPath, + webhookUrl: account.config.webhookUrl, + dmPolicy: account.config.dm?.policy ?? "pairing", + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const statusSink = createAccountStatusSink({ + accountId: account.accountId, + setStatus: ctx.setStatus, + }); + ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`); + const { resolveGoogleChatWebhookPath, startGoogleChatMonitor } = + await loadGoogleChatChannelRuntime(); + statusSink({ + running: true, + lastStartAt: Date.now(), + webhookPath: resolveGoogleChatWebhookPath({ account }), + audienceType: account.config.audienceType, + audience: account.config.audience, + }); + await runPassiveAccountLifecycle({ + abortSignal: ctx.abortSignal, + start: async () => + await startGoogleChatMonitor({ + account, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + webhookPath: account.config.webhookPath, + webhookUrl: account.config.webhookUrl, + statusSink, + }), + stop: async (unregister) => { + unregister?.(); + }, + onStop: async () => { + statusSink({ + running: false, + lastStopAt: Date.now(), + }); + }, + }); + }, + }, + }, + pairing: { + text: { + idLabel: "googlechatUserId", + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: (entry) => formatAllowFromEntry(entry), + notify: async ({ cfg, id, message }) => { + const account = resolveGoogleChatAccount({ cfg: cfg }); + if (account.credentialSource === "none") { + return; + } + const user = normalizeGoogleChatTarget(id) ?? id; + const target = isGoogleChatUserTarget(user) ? user : `users/${user}`; + const space = await resolveGoogleChatOutboundSpace({ account, target }); + const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime(); + await sendGoogleChatMessage({ + account, + space, + text: message, + }); + }, + }, }, security: { - resolveDmPolicy: resolveGoogleChatDmPolicy, + dm: { + channelKey: "googlechat", + resolvePolicy: (account) => account.config.dm?.policy, + resolveAllowFrom: (account) => account.config.dm?.allowFrom, + allowFromPathSuffix: "dm.", + normalizeEntry: (raw) => formatAllowFromEntry(raw), + }, collectWarnings: collectGoogleChatSecurityWarnings, }, - groups: { - resolveRequireMention: resolveGoogleChatGroupRequireMention, - }, threading: { - resolveReplyToMode: createTopLevelChannelReplyToModeResolver("googlechat"), + topLevelReplyToMode: "googlechat", }, - messaging: { - normalizeTarget: normalizeGoogleChatTarget, - targetResolver: { - looksLikeId: (raw, normalized) => { - const value = normalized ?? raw.trim(); - return isGoogleChatSpaceTarget(value) || isGoogleChatUserTarget(value); - }, - hint: "", - }, - }, - directory: createChannelDirectoryAdapter({ - listPeers: async (params) => - listResolvedDirectoryUserEntriesFromAllowFrom({ - ...params, - resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }), - resolveAllowFrom: (account) => account.config.dm?.allowFrom, - normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry, - }), - listGroups: async (params) => - listResolvedDirectoryGroupEntriesFromMapKeys({ - ...params, - resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }), - resolveGroups: (account) => account.config.groups, - }), - }), - resolver: { - resolveTargets: async ({ inputs, kind }) => { - const resolved = inputs.map((input) => { - const normalized = normalizeGoogleChatTarget(input); - if (!normalized) { - return { input, resolved: false, note: "empty target" }; - } - if (kind === "user" && isGoogleChatUserTarget(normalized)) { - return { input, resolved: true, id: normalized }; - } - if (kind === "group" && isGoogleChatSpaceTarget(normalized)) { - return { input, resolved: true, id: normalized }; - } - return { - input, - resolved: false, - note: "use spaces/{space} or users/{user}", - }; - }); - return resolved; - }, - }, - actions: googlechatActions, outbound: { - deliveryMode: "direct", - chunker: (text, limit) => getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit), - chunkerMode: "markdown", - textChunkLimit: 4000, - resolveTarget: ({ to }) => { - const trimmed = to?.trim() ?? ""; + base: { + deliveryMode: "direct", + chunker: (text, limit) => getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim() ?? ""; - if (trimmed) { - const normalized = normalizeGoogleChatTarget(trimmed); - if (!normalized) { - return { - ok: false, - error: missingTargetError("Google Chat", ""), - }; + if (trimmed) { + const normalized = normalizeGoogleChatTarget(trimmed); + if (!normalized) { + return { + ok: false, + error: missingTargetError("Google Chat", ""), + }; + } + return { ok: true, to: normalized }; } - return { ok: true, to: normalized }; - } - return { - ok: false, - error: missingTargetError("Google Chat", ""), - }; + return { + ok: false, + error: missingTargetError("Google Chat", ""), + }; + }, }, - ...createAttachedChannelResultAdapter({ + attachedResults: { channel: "googlechat", sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { const account = resolveGoogleChatAccount({ @@ -356,114 +462,6 @@ export const googlechatPlugin: ChannelPlugin = { chatId: space, }; }, - }), - }, - status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - collectStatusIssues: (accounts): ChannelStatusIssue[] => - accounts.flatMap((entry) => { - const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID); - const enabled = entry.enabled !== false; - const configured = entry.configured === true; - if (!enabled || !configured) { - return []; - } - const issues: ChannelStatusIssue[] = []; - if (!entry.audience) { - issues.push({ - channel: "googlechat", - accountId, - kind: "config", - message: "Google Chat audience is missing (set channels.googlechat.audience).", - fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.", - }); - } - if (!entry.audienceType) { - issues.push({ - channel: "googlechat", - accountId, - kind: "config", - message: "Google Chat audienceType is missing (app-url or project-number).", - fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.", - }); - } - return issues; - }), - buildChannelSummary: ({ snapshot }) => - buildPassiveProbedChannelStatusSummary(snapshot, { - credentialSource: snapshot.credentialSource ?? "none", - audienceType: snapshot.audienceType ?? null, - audience: snapshot.audience ?? null, - webhookPath: snapshot.webhookPath ?? null, - webhookUrl: snapshot.webhookUrl ?? null, - }), - probeAccount: async ({ account }) => - (await loadGoogleChatChannelRuntime()).probeGoogleChat(account), - buildAccountSnapshot: ({ account, runtime, probe }) => { - const base = buildComputedAccountStatusSnapshot({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.credentialSource !== "none", - runtime, - probe, - }); - return { - ...base, - credentialSource: account.credentialSource, - audienceType: account.config.audienceType, - audience: account.config.audience, - webhookPath: account.config.webhookPath, - webhookUrl: account.config.webhookUrl, - dmPolicy: account.config.dm?.policy ?? "pairing", - }; }, }, - gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - const statusSink = createAccountStatusSink({ - accountId: account.accountId, - setStatus: ctx.setStatus, - }); - ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`); - const { resolveGoogleChatWebhookPath, startGoogleChatMonitor } = - await loadGoogleChatChannelRuntime(); - statusSink({ - running: true, - lastStartAt: Date.now(), - webhookPath: resolveGoogleChatWebhookPath({ account }), - audienceType: account.config.audienceType, - audience: account.config.audience, - }); - await runPassiveAccountLifecycle({ - abortSignal: ctx.abortSignal, - start: async () => - await startGoogleChatMonitor({ - account, - config: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - webhookPath: account.config.webhookPath, - webhookUrl: account.config.webhookUrl, - statusSink, - }), - stop: async (unregister) => { - unregister?.(); - }, - onStop: async () => { - statusSink({ - running: false, - lastStopAt: Date.now(), - }); - }, - }); - }, - }, -}; +}); diff --git a/extensions/huggingface/index.ts b/extensions/huggingface/index.ts index 6f50743f43c..311041825d4 100644 --- a/extensions/huggingface/index.ts +++ b/extensions/huggingface/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { applyHuggingfaceConfig, HUGGINGFACE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildHuggingfaceProvider } from "./provider-catalog.js"; diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index 579f469d595..f436325c446 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { isRecord } from "openclaw/plugin-sdk/text-runtime"; import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "./onboard.js"; diff --git a/extensions/lobster/index.ts b/extensions/lobster/index.ts index e6e586af9c5..60db95937fe 100644 --- a/extensions/lobster/index.ts +++ b/extensions/lobster/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolFactory } from "./runtime-api.js"; import { createLobsterTool } from "./src/lobster-tool.js"; diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index c163f34e1a1..8c57a19e731 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -1,5 +1,5 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; import type { MemoryPromptSectionBuilder } from "openclaw/plugin-sdk/memory-core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; export const buildPromptSection: MemoryPromptSectionBuilder = ({ availableTools, diff --git a/extensions/microsoft/index.ts b/extensions/microsoft/index.ts index e0e39e3a18f..8a383faf277 100644 --- a/extensions/microsoft/index.ts +++ b/extensions/microsoft/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { buildMicrosoftSpeechProvider } from "openclaw/plugin-sdk/speech"; export default definePluginEntry({ diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index cfb77d3a012..d3c919f785f 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { mistralMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "./onboard.js"; diff --git a/extensions/modelstudio/index.ts b/extensions/modelstudio/index.ts index fc5dab4c4f8..eeb0b46e89f 100644 --- a/extensions/modelstudio/index.ts +++ b/extensions/modelstudio/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { diff --git a/extensions/nvidia/index.ts b/extensions/nvidia/index.ts index a5018e63579..ce67ef562d4 100644 --- a/extensions/nvidia/index.ts +++ b/extensions/nvidia/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { buildNvidiaProvider } from "./provider-catalog.js"; diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 41b225ef871..824185e6bf8 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -5,7 +5,7 @@ import { type ProviderAuthMethodNonInteractiveContext, type ProviderAuthResult, type ProviderDiscoveryContext, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/plugin-entry"; import { OLLAMA_DEFAULT_BASE_URL, resolveOllamaApiBase } from "openclaw/plugin-sdk/provider-models"; const PROVIDER_ID = "ollama"; diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index 8ef9b6ea0b4..8ea65e712b0 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { OPENCODE_GO_DEFAULT_MODEL_REF } from "openclaw/plugin-sdk/provider-models"; import { applyOpencodeGoConfig } from "./onboard.js"; diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index 9649ff6e83b..ddf60052d7f 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "openclaw/plugin-sdk/provider-models"; import { applyOpencodeZenConfig } from "./onboard.js"; diff --git a/extensions/perplexity/index.ts b/extensions/perplexity/index.ts index 45ed12139f7..4e1a57c259a 100644 --- a/extensions/perplexity/index.ts +++ b/extensions/perplexity/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createPerplexityWebSearchProvider } from "./src/perplexity-web-search-provider.js"; export default definePluginEntry({ diff --git a/extensions/qianfan/index.ts b/extensions/qianfan/index.ts index 0bb9c7760f6..c2f3147f135 100644 --- a/extensions/qianfan/index.ts +++ b/extensions/qianfan/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "./onboard.js"; diff --git a/extensions/sglang/index.ts b/extensions/sglang/index.ts index eb6b302ee01..7f9cc7e757a 100644 --- a/extensions/sglang/index.ts +++ b/extensions/sglang/index.ts @@ -8,7 +8,7 @@ import { definePluginEntry, type OpenClawPluginApi, type ProviderAuthMethodNonInteractiveContext, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/plugin-entry"; const PROVIDER_ID = "sglang"; diff --git a/extensions/synthetic/index.ts b/extensions/synthetic/index.ts index 360e4124cdd..5a88f19309a 100644 --- a/extensions/synthetic/index.ts +++ b/extensions/synthetic/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applySyntheticConfig, SYNTHETIC_DEFAULT_MODEL_REF } from "./onboard.js"; diff --git a/extensions/tavily/index.ts b/extensions/tavily/index.ts index f35fda3129d..cefe792b94c 100644 --- a/extensions/tavily/index.ts +++ b/extensions/tavily/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/core"; +import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry"; import { createTavilyExtractTool } from "./src/tavily-extract-tool.js"; import { createTavilyWebSearchProvider } from "./src/tavily-search-provider.js"; import { createTavilySearchTool } from "./src/tavily-search-tool.js"; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 5a481ba8ac3..64c1128de5e 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -2,17 +2,10 @@ import { buildDmGroupAccountAllowlistAdapter, createNestedAllowlistOverrideResolver, } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; -import { - createPairingPrefixStripper, - createTextPairingAdapter, -} from "openclaw/plugin-sdk/channel-pairing"; +import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; -import { - attachChannelToResult, - createAttachedChannelResultAdapter, -} from "openclaw/plugin-sdk/channel-send-result"; -import { createTopLevelChannelReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; +import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; +import { createChatChannelPlugin } from "openclaw/plugin-sdk/core"; import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra-runtime"; @@ -32,7 +25,6 @@ import { PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, - type ChannelPlugin, type ChannelMessageActionAdapter, type OpenClawConfig, } from "../runtime-api.js"; @@ -279,14 +271,6 @@ const telegramMessageActions: ChannelMessageActionAdapter = { }, }; -const resolveTelegramDmPolicy = createScopedDmSecurityResolver({ - channelKey: "telegram", - resolvePolicy: (account) => account.config.dmPolicy, - resolveAllowFrom: (account) => account.config.allowFrom, - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""), -}); - const resolveTelegramAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({ resolveRecord: (account: ResolvedTelegramAccount) => account.config.groups, outerLabel: (groupId) => groupId, @@ -317,214 +301,440 @@ const collectTelegramSecurityWarnings = }, }); -export const telegramPlugin: ChannelPlugin = { - ...createTelegramPluginBase({ - setupWizard: telegramSetupWizard, - setup: telegramSetupAdapter, - }), - pairing: createTextPairingAdapter({ - idLabel: "telegramUserId", - message: PAIRING_APPROVED_MESSAGE, - normalizeAllowEntry: createPairingPrefixStripper(/^(telegram|tg):/i), - notify: async ({ cfg, id, message }) => { - const { token } = getTelegramRuntime().channel.telegram.resolveTelegramToken(cfg); - if (!token) { - throw new Error("telegram token not configured"); - } - await getTelegramRuntime().channel.telegram.sendMessageTelegram(id, message, { - token, - }); +export const telegramPlugin = createChatChannelPlugin({ + base: { + ...createTelegramPluginBase({ + setupWizard: telegramSetupWizard, + setup: telegramSetupAdapter, + }), + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "telegram", + resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), + normalize: ({ cfg, accountId, values }) => + telegramConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), + resolveDmAllowFrom: (account) => account.config.allowFrom, + resolveGroupAllowFrom: (account) => account.config.groupAllowFrom, + resolveDmPolicy: (account) => account.config.dmPolicy, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveGroupOverrides: resolveTelegramAllowlistGroupOverrides, + }), + bindings: { + compileConfiguredBinding: ({ conversationId }) => + normalizeTelegramAcpConversationId(conversationId), + matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => + matchTelegramAcpConversation({ + bindingConversationId: compiledBinding.conversationId, + conversationId, + parentConversationId, + }), + }, + groups: { + resolveRequireMention: resolveTelegramGroupRequireMention, + resolveToolPolicy: resolveTelegramGroupToolPolicy, + }, + messaging: { + normalizeTarget: normalizeTelegramMessagingTarget, + parseExplicitTarget: ({ raw }) => parseTelegramExplicitTarget(raw), + inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType, + formatTargetDisplay: ({ target, display, kind }) => { + const formatted = display?.trim(); + if (formatted) { + return formatted; + } + const trimmedTarget = target.trim(); + if (!trimmedTarget) { + return trimmedTarget; + } + const withoutProvider = trimmedTarget.replace(/^(telegram|tg):/i, ""); + if (kind === "user" || /^user:/i.test(withoutProvider)) { + return `@${withoutProvider.replace(/^user:/i, "")}`; + } + if (/^channel:/i.test(withoutProvider)) { + return `#${withoutProvider.replace(/^channel:/i, "")}`; + } + return withoutProvider; + }, + resolveOutboundSessionRoute: (params) => resolveTelegramOutboundSessionRoute(params), + targetResolver: { + looksLikeId: looksLikeTelegramTargetId, + hint: "", + }, + }, + lifecycle: { + onAccountConfigChanged: async ({ prevCfg, nextCfg, accountId }) => { + const previousToken = resolveTelegramAccount({ cfg: prevCfg, accountId }).token.trim(); + const nextToken = resolveTelegramAccount({ cfg: nextCfg, accountId }).token.trim(); + if (previousToken !== nextToken) { + const { deleteTelegramUpdateOffset } = await import("./update-offset-store.js"); + await deleteTelegramUpdateOffset({ accountId }); + } + }, + onAccountRemoved: async ({ accountId }) => { + const { deleteTelegramUpdateOffset } = await import("./update-offset-store.js"); + await deleteTelegramUpdateOffset({ accountId }); + }, + }, + execApprovals: { + getInitiatingSurfaceState: ({ cfg, accountId }) => + isTelegramExecApprovalClientEnabled({ cfg, accountId }) + ? { kind: "enabled" } + : { kind: "disabled" }, + hasConfiguredDmRoute: ({ cfg }) => hasTelegramExecApprovalDmRoute(cfg), + shouldSuppressForwardingFallback: ({ cfg, target, request }) => { + const channel = normalizeMessageChannel(target.channel) ?? target.channel; + if (channel !== "telegram") { + return false; + } + const requestChannel = normalizeMessageChannel(request.request.turnSourceChannel ?? ""); + if (requestChannel !== "telegram") { + return false; + } + const accountId = target.accountId?.trim() || request.request.turnSourceAccountId?.trim(); + return isTelegramExecApprovalClientEnabled({ cfg, accountId }); + }, + buildPendingPayload: ({ request, nowMs }) => { + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: request.id, + approvalSlug: request.id.slice(0, 8), + approvalCommandId: request.id, + command: resolveExecApprovalCommandDisplay(request.request).commandText, + cwd: request.request.cwd ?? undefined, + host: request.request.host === "node" ? "node" : "gateway", + nodeId: request.request.nodeId ?? undefined, + expiresAtMs: request.expiresAtMs, + nowMs, + }); + const buttons = buildTelegramExecApprovalButtons(request.id); + if (!buttons) { + return payload; + } + return { + ...payload, + channelData: { + ...payload.channelData, + telegram: { + buttons, + }, + }, + }; + }, + beforeDeliverPending: async ({ cfg, target, payload }) => { + const hasExecApprovalData = + payload.channelData && + typeof payload.channelData === "object" && + !Array.isArray(payload.channelData) && + payload.channelData.execApproval; + if (!hasExecApprovalData) { + return; + } + const threadId = + typeof target.threadId === "number" + ? target.threadId + : typeof target.threadId === "string" + ? Number.parseInt(target.threadId, 10) + : undefined; + await sendTypingTelegram(target.to, { + cfg, + accountId: target.accountId ?? undefined, + ...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}), + }).catch(() => {}); + }, + }, + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params), + listGroups: async (params) => listTelegramDirectoryGroupsFromConfig(params), + }), + actions: telegramMessageActions, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: collectTelegramStatusIssues, + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), + probeAccount: async ({ account, timeoutMs }) => + probeTelegram(account.token, timeoutMs, { + accountId: account.accountId, + proxyUrl: account.config.proxy, + network: account.config.network, + apiRoot: account.config.apiRoot, + }), + formatCapabilitiesProbe: ({ probe }) => { + const lines = []; + if (probe?.bot?.username) { + const botId = probe.bot.id ? ` (${probe.bot.id})` : ""; + lines.push({ text: `Bot: @${probe.bot.username}${botId}` }); + } + const flags: string[] = []; + if (typeof probe?.bot?.canJoinGroups === "boolean") { + flags.push(`joinGroups=${probe.bot.canJoinGroups}`); + } + if (typeof probe?.bot?.canReadAllGroupMessages === "boolean") { + flags.push(`readAllGroupMessages=${probe.bot.canReadAllGroupMessages}`); + } + if (typeof probe?.bot?.supportsInlineQueries === "boolean") { + flags.push(`inlineQueries=${probe.bot.supportsInlineQueries}`); + } + if (flags.length > 0) { + lines.push({ text: `Flags: ${flags.join(" ")}` }); + } + if (probe?.webhook?.url !== undefined) { + lines.push({ text: `Webhook: ${probe.webhook.url || "none"}` }); + } + return lines; + }, + auditAccount: async ({ account, timeoutMs, probe, cfg }) => { + const groups = + cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? + cfg.channels?.telegram?.groups; + const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } = + collectTelegramUnmentionedGroupIds(groups); + if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) { + return undefined; + } + const botId = probe?.ok && probe.bot?.id != null ? probe.bot.id : null; + if (!botId) { + return { + ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups, + checkedGroups: 0, + unresolvedGroups, + hasWildcardUnmentionedGroups, + groups: [], + elapsedMs: 0, + }; + } + const audit = await auditTelegramGroupMembership({ + token: account.token, + botId, + groupIds, + proxyUrl: account.config.proxy, + network: account.config.network, + apiRoot: account.config.apiRoot, + timeoutMs, + }); + return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups }; + }, + buildAccountSnapshot: ({ account, cfg, runtime, probe, audit }) => { + const configuredFromStatus = resolveConfiguredFromCredentialStatuses(account); + const ownerAccountId = findTelegramTokenOwnerAccountId({ + cfg, + accountId: account.accountId, + }); + const duplicateTokenReason = ownerAccountId + ? formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }) + : null; + const configured = + (configuredFromStatus ?? Boolean(account.token?.trim())) && !ownerAccountId; + const groups = + cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? + cfg.channels?.telegram?.groups; + const allowUnmentionedGroups = + groups?.["*"]?.requireMention === false || + Object.entries(groups ?? {}).some( + ([key, value]) => key !== "*" && value?.requireMention === false, + ); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + ...projectCredentialSnapshotFields(account), + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? duplicateTokenReason, + mode: runtime?.mode ?? (account.config.webhookUrl ? "webhook" : "polling"), + probe, + audit, + allowUnmentionedGroups, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const ownerAccountId = findTelegramTokenOwnerAccountId({ + cfg: ctx.cfg, + accountId: account.accountId, + }); + if (ownerAccountId) { + const reason = formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }); + ctx.log?.error?.(`[${account.accountId}] ${reason}`); + throw new Error(reason); + } + const token = (account.token ?? "").trim(); + let telegramBotLabel = ""; + try { + const probe = await probeTelegram(token, 2500, { + accountId: account.accountId, + proxyUrl: account.config.proxy, + network: account.config.network, + apiRoot: account.config.apiRoot, + }); + const username = probe.ok ? probe.bot?.username?.trim() : null; + if (username) { + telegramBotLabel = ` (@${username})`; + } + } catch (err) { + if (getTelegramRuntime().logging.shouldLogVerbose()) { + ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); + } + } + ctx.log?.info(`[${account.accountId}] starting provider${telegramBotLabel}`); + return monitorTelegramProvider({ + token, + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + useWebhook: Boolean(account.config.webhookUrl), + webhookUrl: account.config.webhookUrl, + webhookSecret: account.config.webhookSecret, + webhookPath: account.config.webhookPath, + webhookHost: account.config.webhookHost, + webhookPort: account.config.webhookPort, + webhookCertPath: account.config.webhookCertPath, + }); + }, + logoutAccount: async ({ accountId, cfg }) => { + const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? ""; + const nextCfg = { ...cfg } as OpenClawConfig; + const nextTelegram = cfg.channels?.telegram ? { ...cfg.channels.telegram } : undefined; + let cleared = false; + let changed = false; + if (nextTelegram) { + if (accountId === DEFAULT_ACCOUNT_ID && nextTelegram.botToken) { + delete nextTelegram.botToken; + cleared = true; + changed = true; + } + const accountCleanup = clearAccountEntryFields({ + accounts: nextTelegram.accounts, + accountId, + fields: ["botToken"], + }); + if (accountCleanup.changed) { + changed = true; + if (accountCleanup.cleared) { + cleared = true; + } + if (accountCleanup.nextAccounts) { + nextTelegram.accounts = accountCleanup.nextAccounts; + } else { + delete nextTelegram.accounts; + } + } + } + if (changed) { + if (nextTelegram && Object.keys(nextTelegram).length > 0) { + nextCfg.channels = { ...nextCfg.channels, telegram: nextTelegram }; + } else { + const nextChannels = { ...nextCfg.channels }; + delete nextChannels.telegram; + if (Object.keys(nextChannels).length > 0) { + nextCfg.channels = nextChannels; + } else { + delete nextCfg.channels; + } + } + } + const resolved = resolveTelegramAccount({ + cfg: changed ? nextCfg : cfg, + accountId, + }); + const loggedOut = resolved.tokenSource === "none"; + if (changed) { + await getTelegramRuntime().config.writeConfigFile(nextCfg); + } + return { cleared, envToken: Boolean(envToken), loggedOut }; + }, + }, + }, + pairing: { + text: { + idLabel: "telegramUserId", + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(telegram|tg):/i), + notify: async ({ cfg, id, message }) => { + const { token } = getTelegramRuntime().channel.telegram.resolveTelegramToken(cfg); + if (!token) { + throw new Error("telegram token not configured"); + } + await getTelegramRuntime().channel.telegram.sendMessageTelegram(id, message, { + token, + }); + }, }, - }), - allowlist: buildDmGroupAccountAllowlistAdapter({ - channelId: "telegram", - resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), - normalize: ({ cfg, accountId, values }) => - telegramConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolveDmAllowFrom: (account) => account.config.allowFrom, - resolveGroupAllowFrom: (account) => account.config.groupAllowFrom, - resolveDmPolicy: (account) => account.config.dmPolicy, - resolveGroupPolicy: (account) => account.config.groupPolicy, - resolveGroupOverrides: resolveTelegramAllowlistGroupOverrides, - }), - bindings: { - compileConfiguredBinding: ({ conversationId }) => - normalizeTelegramAcpConversationId(conversationId), - matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => - matchTelegramAcpConversation({ - bindingConversationId: compiledBinding.conversationId, - conversationId, - parentConversationId, - }), }, security: { - resolveDmPolicy: resolveTelegramDmPolicy, + dm: { + channelKey: "telegram", + resolvePolicy: (account) => account.config.dmPolicy, + resolveAllowFrom: (account) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""), + }, collectWarnings: collectTelegramSecurityWarnings, }, - groups: { - resolveRequireMention: resolveTelegramGroupRequireMention, - resolveToolPolicy: resolveTelegramGroupToolPolicy, - }, threading: { - resolveReplyToMode: createTopLevelChannelReplyToModeResolver("telegram"), + topLevelReplyToMode: "telegram", resolveAutoThreadId: ({ to, toolContext, replyToId }) => replyToId ? undefined : resolveTelegramAutoThreadId({ to, toolContext }), }, - messaging: { - normalizeTarget: normalizeTelegramMessagingTarget, - parseExplicitTarget: ({ raw }) => parseTelegramExplicitTarget(raw), - inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType, - formatTargetDisplay: ({ target, display, kind }) => { - const formatted = display?.trim(); - if (formatted) { - return formatted; - } - const trimmedTarget = target.trim(); - if (!trimmedTarget) { - return trimmedTarget; - } - const withoutProvider = trimmedTarget.replace(/^(telegram|tg):/i, ""); - if (kind === "user" || /^user:/i.test(withoutProvider)) { - return `@${withoutProvider.replace(/^user:/i, "")}`; - } - if (/^channel:/i.test(withoutProvider)) { - return `#${withoutProvider.replace(/^channel:/i, "")}`; - } - return withoutProvider; - }, - resolveOutboundSessionRoute: (params) => resolveTelegramOutboundSessionRoute(params), - targetResolver: { - looksLikeId: looksLikeTelegramTargetId, - hint: "", - }, - }, - lifecycle: { - onAccountConfigChanged: async ({ prevCfg, nextCfg, accountId }) => { - const previousToken = resolveTelegramAccount({ cfg: prevCfg, accountId }).token.trim(); - const nextToken = resolveTelegramAccount({ cfg: nextCfg, accountId }).token.trim(); - if (previousToken !== nextToken) { - const { deleteTelegramUpdateOffset } = await import("./update-offset-store.js"); - await deleteTelegramUpdateOffset({ accountId }); - } - }, - onAccountRemoved: async ({ accountId }) => { - const { deleteTelegramUpdateOffset } = await import("./update-offset-store.js"); - await deleteTelegramUpdateOffset({ accountId }); - }, - }, - execApprovals: { - getInitiatingSurfaceState: ({ cfg, accountId }) => - isTelegramExecApprovalClientEnabled({ cfg, accountId }) - ? { kind: "enabled" } - : { kind: "disabled" }, - hasConfiguredDmRoute: ({ cfg }) => hasTelegramExecApprovalDmRoute(cfg), - shouldSuppressForwardingFallback: ({ cfg, target, request }) => { - const channel = normalizeMessageChannel(target.channel) ?? target.channel; - if (channel !== "telegram") { - return false; - } - const requestChannel = normalizeMessageChannel(request.request.turnSourceChannel ?? ""); - if (requestChannel !== "telegram") { - return false; - } - const accountId = target.accountId?.trim() || request.request.turnSourceAccountId?.trim(); - return isTelegramExecApprovalClientEnabled({ cfg, accountId }); - }, - buildPendingPayload: ({ request, nowMs }) => { - const payload = buildExecApprovalPendingReplyPayload({ - approvalId: request.id, - approvalSlug: request.id.slice(0, 8), - approvalCommandId: request.id, - command: resolveExecApprovalCommandDisplay(request.request).commandText, - cwd: request.request.cwd ?? undefined, - host: request.request.host === "node" ? "node" : "gateway", - nodeId: request.request.nodeId ?? undefined, - expiresAtMs: request.expiresAtMs, - nowMs, - }); - const buttons = buildTelegramExecApprovalButtons(request.id); - if (!buttons) { - return payload; - } - return { - ...payload, - channelData: { - ...payload.channelData, - telegram: { - buttons, - }, - }, - }; - }, - beforeDeliverPending: async ({ cfg, target, payload }) => { - const hasExecApprovalData = - payload.channelData && - typeof payload.channelData === "object" && - !Array.isArray(payload.channelData) && - payload.channelData.execApproval; - if (!hasExecApprovalData) { - return; - } - const threadId = - typeof target.threadId === "number" - ? target.threadId - : typeof target.threadId === "string" - ? Number.parseInt(target.threadId, 10) - : undefined; - await sendTypingTelegram(target.to, { - cfg, - accountId: target.accountId ?? undefined, - ...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}), - }).catch(() => {}); - }, - }, - directory: createChannelDirectoryAdapter({ - listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params), - listGroups: async (params) => listTelegramDirectoryGroupsFromConfig(params), - }), - actions: telegramMessageActions, - setup: telegramSetupAdapter, outbound: { - deliveryMode: "direct", - chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit), - chunkerMode: "markdown", - textChunkLimit: 4000, - pollMaxOptions: 10, - shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData), - resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => - typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, - sendPayload: async ({ - cfg, - to, - payload, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - silent, - forceDocument, - }) => { - const send = - resolveOutboundSendDep(deps, "telegram") ?? - getTelegramRuntime().channel.telegram.sendMessageTelegram; - const result = await sendTelegramPayloadMessages({ - send, + base: { + deliveryMode: "direct", + chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + pollMaxOptions: 10, + shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData), + resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => + typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, + sendPayload: async ({ + cfg, to, payload, - baseOpts: buildTelegramSendOptions({ - cfg, - mediaLocalRoots, - accountId, - replyToId, - threadId, - silent, - forceDocument, - }), - }); - return attachChannelToResult("telegram", result); + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + silent, + forceDocument, + }) => { + const send = + resolveOutboundSendDep(deps, "telegram") ?? + getTelegramRuntime().channel.telegram.sendMessageTelegram; + const result = await sendTelegramPayloadMessages({ + send, + to, + payload, + baseOpts: buildTelegramSendOptions({ + cfg, + mediaLocalRoots, + accountId, + replyToId, + threadId, + silent, + forceDocument, + }), + }); + return attachChannelToResult("telegram", result); + }, }, - ...createAttachedChannelResultAdapter({ + attachedResults: { channel: "telegram", sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => await sendTelegramOutbound({ @@ -569,221 +779,6 @@ export const telegramPlugin: ChannelPlugin buildTokenChannelStatusSummary(snapshot), - probeAccount: async ({ account, timeoutMs }) => - probeTelegram(account.token, timeoutMs, { - accountId: account.accountId, - proxyUrl: account.config.proxy, - network: account.config.network, - apiRoot: account.config.apiRoot, - }), - formatCapabilitiesProbe: ({ probe }) => { - const lines = []; - if (probe?.bot?.username) { - const botId = probe.bot.id ? ` (${probe.bot.id})` : ""; - lines.push({ text: `Bot: @${probe.bot.username}${botId}` }); - } - const flags: string[] = []; - if (typeof probe?.bot?.canJoinGroups === "boolean") { - flags.push(`joinGroups=${probe.bot.canJoinGroups}`); - } - if (typeof probe?.bot?.canReadAllGroupMessages === "boolean") { - flags.push(`readAllGroupMessages=${probe.bot.canReadAllGroupMessages}`); - } - if (typeof probe?.bot?.supportsInlineQueries === "boolean") { - flags.push(`inlineQueries=${probe.bot.supportsInlineQueries}`); - } - if (flags.length > 0) { - lines.push({ text: `Flags: ${flags.join(" ")}` }); - } - if (probe?.webhook?.url !== undefined) { - lines.push({ text: `Webhook: ${probe.webhook.url || "none"}` }); - } - return lines; - }, - auditAccount: async ({ account, timeoutMs, probe, cfg }) => { - const groups = - cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? - cfg.channels?.telegram?.groups; - const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } = - collectTelegramUnmentionedGroupIds(groups); - if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) { - return undefined; - } - const botId = probe?.ok && probe.bot?.id != null ? probe.bot.id : null; - if (!botId) { - return { - ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups, - checkedGroups: 0, - unresolvedGroups, - hasWildcardUnmentionedGroups, - groups: [], - elapsedMs: 0, - }; - } - const audit = await auditTelegramGroupMembership({ - token: account.token, - botId, - groupIds, - proxyUrl: account.config.proxy, - network: account.config.network, - apiRoot: account.config.apiRoot, - timeoutMs, - }); - return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups }; - }, - buildAccountSnapshot: ({ account, cfg, runtime, probe, audit }) => { - const configuredFromStatus = resolveConfiguredFromCredentialStatuses(account); - const ownerAccountId = findTelegramTokenOwnerAccountId({ - cfg, - accountId: account.accountId, - }); - const duplicateTokenReason = ownerAccountId - ? formatDuplicateTelegramTokenReason({ - accountId: account.accountId, - ownerAccountId, - }) - : null; - const configured = - (configuredFromStatus ?? Boolean(account.token?.trim())) && !ownerAccountId; - const groups = - cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? - cfg.channels?.telegram?.groups; - const allowUnmentionedGroups = - groups?.["*"]?.requireMention === false || - Object.entries(groups ?? {}).some( - ([key, value]) => key !== "*" && value?.requireMention === false, - ); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured, - ...projectCredentialSnapshotFields(account), - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? duplicateTokenReason, - mode: runtime?.mode ?? (account.config.webhookUrl ? "webhook" : "polling"), - probe, - audit, - allowUnmentionedGroups, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, - }; }, }, - gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - const ownerAccountId = findTelegramTokenOwnerAccountId({ - cfg: ctx.cfg, - accountId: account.accountId, - }); - if (ownerAccountId) { - const reason = formatDuplicateTelegramTokenReason({ - accountId: account.accountId, - ownerAccountId, - }); - ctx.log?.error?.(`[${account.accountId}] ${reason}`); - throw new Error(reason); - } - const token = (account.token ?? "").trim(); - let telegramBotLabel = ""; - try { - const probe = await probeTelegram(token, 2500, { - accountId: account.accountId, - proxyUrl: account.config.proxy, - network: account.config.network, - apiRoot: account.config.apiRoot, - }); - const username = probe.ok ? probe.bot?.username?.trim() : null; - if (username) { - telegramBotLabel = ` (@${username})`; - } - } catch (err) { - if (getTelegramRuntime().logging.shouldLogVerbose()) { - ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); - } - } - ctx.log?.info(`[${account.accountId}] starting provider${telegramBotLabel}`); - return monitorTelegramProvider({ - token, - accountId: account.accountId, - config: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - useWebhook: Boolean(account.config.webhookUrl), - webhookUrl: account.config.webhookUrl, - webhookSecret: account.config.webhookSecret, - webhookPath: account.config.webhookPath, - webhookHost: account.config.webhookHost, - webhookPort: account.config.webhookPort, - webhookCertPath: account.config.webhookCertPath, - }); - }, - logoutAccount: async ({ accountId, cfg }) => { - const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? ""; - const nextCfg = { ...cfg } as OpenClawConfig; - const nextTelegram = cfg.channels?.telegram ? { ...cfg.channels.telegram } : undefined; - let cleared = false; - let changed = false; - if (nextTelegram) { - if (accountId === DEFAULT_ACCOUNT_ID && nextTelegram.botToken) { - delete nextTelegram.botToken; - cleared = true; - changed = true; - } - const accountCleanup = clearAccountEntryFields({ - accounts: nextTelegram.accounts, - accountId, - fields: ["botToken"], - }); - if (accountCleanup.changed) { - changed = true; - if (accountCleanup.cleared) { - cleared = true; - } - if (accountCleanup.nextAccounts) { - nextTelegram.accounts = accountCleanup.nextAccounts; - } else { - delete nextTelegram.accounts; - } - } - } - if (changed) { - if (nextTelegram && Object.keys(nextTelegram).length > 0) { - nextCfg.channels = { ...nextCfg.channels, telegram: nextTelegram }; - } else { - const nextChannels = { ...nextCfg.channels }; - delete nextChannels.telegram; - if (Object.keys(nextChannels).length > 0) { - nextCfg.channels = nextChannels; - } else { - delete nextCfg.channels; - } - } - } - const resolved = resolveTelegramAccount({ - cfg: changed ? nextCfg : cfg, - accountId, - }); - const loggedOut = resolved.tokenSource === "none"; - if (changed) { - await getTelegramRuntime().config.writeConfigFile(nextCfg); - } - return { cleared, envToken: Boolean(envToken), loggedOut }; - }, - }, -}; +}); diff --git a/extensions/together/index.ts b/extensions/together/index.ts index d4ae42bba82..30ca167003d 100644 --- a/extensions/together/index.ts +++ b/extensions/together/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyTogetherConfig, TOGETHER_DEFAULT_MODEL_REF } from "./onboard.js"; diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index 2cef47dc3c3..e47cbf629f2 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyXaiModelCompat } from "openclaw/plugin-sdk/provider-models"; diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts index ecaa6d96d33..8cf329ef140 100644 --- a/extensions/vercel-ai-gateway/index.ts +++ b/extensions/vercel-ai-gateway/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "./onboard.js"; diff --git a/extensions/vllm/index.ts b/extensions/vllm/index.ts index 7017977861c..524e927db59 100644 --- a/extensions/vllm/index.ts +++ b/extensions/vllm/index.ts @@ -8,7 +8,7 @@ import { definePluginEntry, type OpenClawPluginApi, type ProviderAuthMethodNonInteractiveContext, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/plugin-entry"; const PROVIDER_ID = "vllm"; diff --git a/extensions/volcengine/index.ts b/extensions/volcengine/index.ts index f6b4b020746..9533b9a2f6f 100644 --- a/extensions/volcengine/index.ts +++ b/extensions/volcengine/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard"; import { buildDoubaoCodingProvider, buildDoubaoProvider } from "./provider-catalog.js"; diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts index def263b1cda..513581c0332 100644 --- a/extensions/xiaomi/index.ts +++ b/extensions/xiaomi/index.ts @@ -1,4 +1,4 @@ -import { definePluginEntry } from "openclaw/plugin-sdk/core"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog"; import { PROVIDER_LABELS } from "openclaw/plugin-sdk/provider-usage"; diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index ee4aa0b30bc..fc87031b9b0 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -5,7 +5,7 @@ import { type ProviderAuthMethodNonInteractiveContext, type ProviderResolveDynamicModelContext, type ProviderRuntimeModel, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/plugin-entry"; import { applyAuthProfileConfig, buildApiKeyCredential, diff --git a/src/plugin-sdk/copilot-proxy.ts b/src/plugin-sdk/copilot-proxy.ts index d4a4dec92bf..808cad1dbf7 100644 --- a/src/plugin-sdk/copilot-proxy.ts +++ b/src/plugin-sdk/copilot-proxy.ts @@ -1,7 +1,7 @@ // Narrow plugin-sdk surface for the bundled copilot-proxy plugin. // Keep this list additive and scoped to symbols used under extensions/copilot-proxy. -export { definePluginEntry } from "./core.js"; +export { definePluginEntry } from "./plugin-entry.js"; export type { OpenClawPluginApi, ProviderAuthContext, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 24f99bb3dad..c4d324e2e5e 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -1,21 +1,29 @@ +import { + createScopedAccountReplyToModeResolver, + createTopLevelChannelReplyToModeResolver, +} from "../channels/plugins/threading-helpers.js"; +import type { + ChannelOutboundAdapter, + ChannelPairingAdapter, + ChannelSecurityAdapter, +} from "../channels/plugins/types.adapters.js"; import type { ChannelMessagingAdapter, ChannelOutboundSessionRoute, + ChannelThreadingAdapter, } from "../channels/plugins/types.core.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import { getChatChannelMeta } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/config.js"; +import type { ReplyToMode } from "../config/types.base.js"; import { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js"; import { emptyPluginConfigSchema } from "../plugins/config-schema.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; -import type { - OpenClawPluginApi, - OpenClawPluginCommandDefinition, - OpenClawPluginConfigSchema, - OpenClawPluginDefinition, - PluginCommandContext, - PluginInteractiveTelegramHandlerContext, -} from "../plugins/types.js"; +import type { OpenClawPluginApi, OpenClawPluginConfigSchema } from "../plugins/types.js"; +import { createScopedDmSecurityResolver } from "./channel-config-helpers.js"; +import { createTextPairingAdapter } from "./channel-pairing.js"; +import { createAttachedChannelResultAdapter } from "./channel-send-result.js"; +import { definePluginEntry } from "./plugin-entry.js"; export type { AnyAgentTool, @@ -77,6 +85,7 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { definePluginEntry } from "./plugin-entry.js"; export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; @@ -177,28 +186,11 @@ type DefineChannelPluginEntryOptions OpenClawPluginConfigSchema); setRuntime?: (runtime: PluginRuntime) => void; registerFull?: (api: OpenClawPluginApi) => void; }; -type DefinePluginEntryOptions = { - id: string; - name: string; - description: string; - kind?: OpenClawPluginDefinition["kind"]; - configSchema?: OpenClawPluginConfigSchema | (() => OpenClawPluginConfigSchema); - register: (api: OpenClawPluginApi) => void; -}; - -type DefinedPluginEntry = { - id: string; - name: string; - description: string; - configSchema: OpenClawPluginConfigSchema; - register: NonNullable; -} & Pick; - type CreateChannelPluginBaseOptions = { id: ChannelPlugin["id"]; meta?: Partial["meta"]>>; @@ -235,31 +227,6 @@ type CreatedChannelPluginBase = Pick< > >; -function resolvePluginConfigSchema( - configSchema: DefinePluginEntryOptions["configSchema"] = emptyPluginConfigSchema, -): OpenClawPluginConfigSchema { - return typeof configSchema === "function" ? configSchema() : configSchema; -} - -// Shared generic plugin-entry boilerplate for bundled and third-party plugins. -export function definePluginEntry({ - id, - name, - description, - kind, - configSchema = emptyPluginConfigSchema, - register, -}: DefinePluginEntryOptions): DefinedPluginEntry { - return { - id, - name, - description, - ...(kind ? { kind } : {}), - configSchema: resolvePluginConfigSchema(configSchema), - register, - }; -} - // Shared channel-plugin entry boilerplate for bundled and third-party channels. export function defineChannelPluginEntry({ id, @@ -291,6 +258,161 @@ export function defineSetupPluginEntry(plugin: TPlugin) { return { plugin }; } +type ChatChannelPluginBase = Omit< + ChannelPlugin, + "security" | "pairing" | "threading" | "outbound" +> & + Partial< + Pick< + ChannelPlugin, + "security" | "pairing" | "threading" | "outbound" + > + >; + +type ChatChannelSecurityOptions = { + dm: { + channelKey: string; + resolvePolicy: (account: TResolvedAccount) => string | null | undefined; + resolveAllowFrom: (account: TResolvedAccount) => Array | null | undefined; + resolveFallbackAccountId?: (account: TResolvedAccount) => string | null | undefined; + defaultPolicy?: string; + allowFromPathSuffix?: string; + policyPathSuffix?: string; + approveChannelId?: string; + approveHint?: string; + normalizeEntry?: (raw: string) => string; + }; + collectWarnings?: ChannelSecurityAdapter["collectWarnings"]; +}; + +type ChatChannelPairingOptions = { + text: { + idLabel: string; + message: string; + normalizeAllowEntry?: ChannelPairingAdapter["normalizeAllowEntry"]; + notify: Parameters[0]["notify"]; + }; +}; + +type ChatChannelThreadingReplyModeOptions = + | { topLevelReplyToMode: string } + | { + scopedAccountReplyToMode: { + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => TResolvedAccount; + resolveReplyToMode: ( + account: TResolvedAccount, + chatType?: string | null, + ) => ReplyToMode | null | undefined; + fallback?: ReplyToMode; + }; + } + | { + resolveReplyToMode: NonNullable; + }; + +type ChatChannelThreadingOptions = + ChatChannelThreadingReplyModeOptions & + Omit; + +type ChatChannelAttachedOutboundOptions = { + base: Omit; + attachedResults: Parameters[0]; +}; + +function resolveChatChannelSecurity( + security: + | ChannelSecurityAdapter + | ChatChannelSecurityOptions + | undefined, +): ChannelSecurityAdapter | undefined { + if (!security) { + return undefined; + } + if (!("dm" in security)) { + return security; + } + return { + resolveDmPolicy: createScopedDmSecurityResolver(security.dm), + ...(security.collectWarnings ? { collectWarnings: security.collectWarnings } : {}), + }; +} + +function resolveChatChannelPairing( + pairing: ChannelPairingAdapter | ChatChannelPairingOptions | undefined, +): ChannelPairingAdapter | undefined { + if (!pairing) { + return undefined; + } + if (!("text" in pairing)) { + return pairing; + } + return createTextPairingAdapter(pairing.text); +} + +function resolveChatChannelThreading( + threading: ChannelThreadingAdapter | ChatChannelThreadingOptions | undefined, +): ChannelThreadingAdapter | undefined { + if (!threading) { + return undefined; + } + if (!("topLevelReplyToMode" in threading) && !("scopedAccountReplyToMode" in threading)) { + return threading; + } + + let resolveReplyToMode: ChannelThreadingAdapter["resolveReplyToMode"]; + if ("topLevelReplyToMode" in threading) { + resolveReplyToMode = createTopLevelChannelReplyToModeResolver(threading.topLevelReplyToMode); + } else { + resolveReplyToMode = createScopedAccountReplyToModeResolver( + threading.scopedAccountReplyToMode, + ); + } + + return { + ...threading, + resolveReplyToMode, + }; +} + +function resolveChatChannelOutbound( + outbound: ChannelOutboundAdapter | ChatChannelAttachedOutboundOptions | undefined, +): ChannelOutboundAdapter | undefined { + if (!outbound) { + return undefined; + } + if (!("attachedResults" in outbound)) { + return outbound; + } + return { + ...outbound.base, + ...createAttachedChannelResultAdapter(outbound.attachedResults), + }; +} + +// Shared higher-level builder for chat-style channels that mostly compose +// scoped DM security, text pairing, reply threading, and attached send results. +export function createChatChannelPlugin< + TResolvedAccount extends { accountId?: string | null }, + Probe = unknown, + Audit = unknown, +>(params: { + base: ChatChannelPluginBase; + security?: + | ChannelSecurityAdapter + | ChatChannelSecurityOptions; + pairing?: ChannelPairingAdapter | ChatChannelPairingOptions; + threading?: ChannelThreadingAdapter | ChatChannelThreadingOptions; + outbound?: ChannelOutboundAdapter | ChatChannelAttachedOutboundOptions; +}): ChannelPlugin { + return { + ...params.base, + ...(params.security ? { security: resolveChatChannelSecurity(params.security) } : {}), + ...(params.pairing ? { pairing: resolveChatChannelPairing(params.pairing) } : {}), + ...(params.threading ? { threading: resolveChatChannelThreading(params.threading) } : {}), + ...(params.outbound ? { outbound: resolveChatChannelOutbound(params.outbound) } : {}), + } as ChannelPlugin; +} + // Shared base object for channel plugins that only need to override a few optional surfaces. export function createChannelPluginBase( params: CreateChannelPluginBaseOptions, diff --git a/src/plugin-sdk/diffs.ts b/src/plugin-sdk/diffs.ts index 9884781be8d..9a7b50bdbaa 100644 --- a/src/plugin-sdk/diffs.ts +++ b/src/plugin-sdk/diffs.ts @@ -1,7 +1,7 @@ // Narrow plugin-sdk surface for the bundled diffs plugin. // Keep this list additive and scoped to symbols used under extensions/diffs. -export { definePluginEntry } from "./core.js"; +export { definePluginEntry } from "./plugin-entry.js"; export type { OpenClawConfig } from "../config/config.js"; export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; export type { diff --git a/src/plugin-sdk/llm-task.ts b/src/plugin-sdk/llm-task.ts index b93a3197d26..5b7a7a0a9e5 100644 --- a/src/plugin-sdk/llm-task.ts +++ b/src/plugin-sdk/llm-task.ts @@ -1,7 +1,7 @@ // Narrow plugin-sdk surface for the bundled llm-task plugin. // Keep this list additive and scoped to symbols used under extensions/llm-task. -export { definePluginEntry } from "./core.js"; +export { definePluginEntry } from "./plugin-entry.js"; export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; export { formatThinkingLevels, diff --git a/src/plugin-sdk/lobster.ts b/src/plugin-sdk/lobster.ts index 2434e1be70e..07601b374c3 100644 --- a/src/plugin-sdk/lobster.ts +++ b/src/plugin-sdk/lobster.ts @@ -1,7 +1,7 @@ // Private Lobster plugin helpers for bundled extensions. // Keep this surface narrow and limited to the Lobster workflow/tool contract. -export { definePluginEntry } from "./core.js"; +export { definePluginEntry } from "./plugin-entry.js"; export { applyWindowsSpawnProgramPolicy, materializeWindowsSpawnProgram, diff --git a/src/plugin-sdk/memory-lancedb.ts b/src/plugin-sdk/memory-lancedb.ts index 23d3e2619c8..b987b56d51c 100644 --- a/src/plugin-sdk/memory-lancedb.ts +++ b/src/plugin-sdk/memory-lancedb.ts @@ -1,5 +1,5 @@ // Narrow plugin-sdk surface for the bundled memory-lancedb plugin. // Keep this list additive and scoped to symbols used under extensions/memory-lancedb. -export { definePluginEntry } from "./core.js"; +export { definePluginEntry } from "./plugin-entry.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; diff --git a/src/plugin-sdk/open-prose.ts b/src/plugin-sdk/open-prose.ts index 049370ed986..c361b702120 100644 --- a/src/plugin-sdk/open-prose.ts +++ b/src/plugin-sdk/open-prose.ts @@ -1,5 +1,5 @@ // Narrow plugin-sdk surface for the bundled open-prose plugin. // Keep this list additive and scoped to symbols used under extensions/open-prose. -export { definePluginEntry } from "./core.js"; +export { definePluginEntry } from "./plugin-entry.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; diff --git a/src/plugin-sdk/phone-control.ts b/src/plugin-sdk/phone-control.ts index c116eba1076..27a7124af32 100644 --- a/src/plugin-sdk/phone-control.ts +++ b/src/plugin-sdk/phone-control.ts @@ -1,7 +1,7 @@ // Narrow plugin-sdk surface for the bundled phone-control plugin. // Keep this list additive and scoped to symbols used under extensions/phone-control. -export { definePluginEntry } from "./core.js"; +export { definePluginEntry } from "./plugin-entry.js"; export type { OpenClawPluginApi, OpenClawPluginCommandDefinition, diff --git a/src/plugin-sdk/plugin-entry-guardrails.test.ts b/src/plugin-sdk/plugin-entry-guardrails.test.ts new file mode 100644 index 00000000000..036c362a791 --- /dev/null +++ b/src/plugin-sdk/plugin-entry-guardrails.test.ts @@ -0,0 +1,33 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const REPO_ROOT = resolve(ROOT_DIR, ".."); +const EXTENSIONS_DIR = resolve(REPO_ROOT, "extensions"); +const CORE_PLUGIN_ENTRY_IMPORT_RE = + /import\s*\{[^}]*\bdefinePluginEntry\b[^}]*\}\s*from\s*"openclaw\/plugin-sdk\/core"/; + +describe("plugin entry guardrails", () => { + it("keeps bundled extension entry modules off direct definePluginEntry imports from core", () => { + const failures: string[] = []; + + for (const entry of readdirSync(EXTENSIONS_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const indexPath = resolve(EXTENSIONS_DIR, entry.name, "index.ts"); + try { + const source = readFileSync(indexPath, "utf8"); + if (CORE_PLUGIN_ENTRY_IMPORT_RE.test(source)) { + failures.push(`extensions/${entry.name}/index.ts`); + } + } catch { + // Skip extensions without index.ts entry modules. + } + } + + expect(failures).toEqual([]); + }); +}); diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index b28fdbf2e89..d1b22bb9e91 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -39,6 +39,7 @@ import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime"; import * as matrixRuntimeSharedSdk from "openclaw/plugin-sdk/matrix-runtime-shared"; import * as mediaRuntimeSdk from "openclaw/plugin-sdk/media-runtime"; import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; +import * as pluginEntrySdk from "openclaw/plugin-sdk/plugin-entry"; import * as providerAuthSdk from "openclaw/plugin-sdk/provider-auth"; import * as providerModelsSdk from "openclaw/plugin-sdk/provider-models"; import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup"; @@ -140,6 +141,7 @@ describe("plugin-sdk subpath exports", () => { expect(typeof coreSdk.definePluginEntry).toBe("function"); expect(typeof coreSdk.defineChannelPluginEntry).toBe("function"); expect(typeof coreSdk.defineSetupPluginEntry).toBe("function"); + expect(typeof coreSdk.createChatChannelPlugin).toBe("function"); expect(typeof coreSdk.createChannelPluginBase).toBe("function"); expect(typeof coreSdk.isSecretRef).toBe("function"); expect(typeof coreSdk.optionalStringEnum).toBe("function"); @@ -148,6 +150,10 @@ describe("plugin-sdk subpath exports", () => { expect("registerSandboxBackend" in asExports(coreSdk)).toBe(false); }); + it("re-exports the canonical plugin entry helper from core", () => { + expect(coreSdk.definePluginEntry).toBe(pluginEntrySdk.definePluginEntry); + }); + it("exports routing helpers from the dedicated subpath", () => { expect(typeof routingSdk.buildAgentSessionKey).toBe("function"); expect(typeof routingSdk.resolveThreadSessionKeys).toBe("function"); diff --git a/src/plugin-sdk/thread-ownership.ts b/src/plugin-sdk/thread-ownership.ts index ea8ad079a8c..5c6a6a048cf 100644 --- a/src/plugin-sdk/thread-ownership.ts +++ b/src/plugin-sdk/thread-ownership.ts @@ -1,6 +1,6 @@ // Narrow plugin-sdk surface for the bundled thread-ownership plugin. // Keep this list additive and scoped to symbols used under extensions/thread-ownership. -export { definePluginEntry } from "./core.js"; +export { definePluginEntry } from "./plugin-entry.js"; export type { OpenClawConfig } from "../config/config.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; diff --git a/src/plugin-sdk/voice-call.ts b/src/plugin-sdk/voice-call.ts index a278d645127..2df9528111f 100644 --- a/src/plugin-sdk/voice-call.ts +++ b/src/plugin-sdk/voice-call.ts @@ -1,7 +1,7 @@ // Private helper surface for the bundled voice-call plugin. // Keep this surface narrow and limited to the voice-call feature contract. -export { definePluginEntry } from "./core.js"; +export { definePluginEntry } from "./plugin-entry.js"; export { TtsAutoSchema, TtsConfigSchema,