From 5b4651d9ed1729f707c2b7b93aab0f5b88486b27 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 02:14:07 +0000 Subject: [PATCH] refactor: add plugin sdk runtime scaffolding --- docs/refactor/plugin-sdk.md | 187 +++++++++++++++++++++++++++++++++++ package.json | 1 + src/plugin-sdk/index.ts | 78 +++++++++++++++ src/plugins/loader.ts | 3 + src/plugins/registry.ts | 3 + src/plugins/runtime/index.ts | 96 ++++++++++++++++++ src/plugins/runtime/types.ts | 103 +++++++++++++++++++ src/plugins/types.ts | 4 + 8 files changed, 475 insertions(+) create mode 100644 docs/refactor/plugin-sdk.md create mode 100644 src/plugin-sdk/index.ts create mode 100644 src/plugins/runtime/index.ts create mode 100644 src/plugins/runtime/types.ts diff --git a/docs/refactor/plugin-sdk.md b/docs/refactor/plugin-sdk.md new file mode 100644 index 000000000..833629b44 --- /dev/null +++ b/docs/refactor/plugin-sdk.md @@ -0,0 +1,187 @@ +--- +summary: "Plan: one clean plugin SDK + runtime for all messaging connectors" +read_when: + - Defining or refactoring the plugin architecture + - Migrating channel connectors to the plugin SDK/runtime +--- +# Plugin SDK + Runtime Refactor Plan + +Goal: every messaging connector is a plugin (bundled or external) using one stable API. +No plugin imports from `src/**` directly. All dependencies go through the SDK or runtime. + +## Why now +- Current connectors mix patterns: direct core imports, dist-only bridges, and custom helpers. +- This makes upgrades brittle and blocks a clean external plugin surface. + +## Target architecture (two layers) + +### 1) Plugin SDK (compile-time, stable, publishable) +Scope: types, helpers, and config utilities. No runtime state, no side effects. + +Contents (examples): +- Types: `ChannelPlugin`, adapters, `ChannelMeta`, `ChannelCapabilities`, `ChannelDirectoryEntry`. +- Config helpers: `buildChannelConfigSchema`, `setAccountEnabledInConfigSection`, `deleteAccountFromConfigSection`, + `applyAccountNameToChannelSection`. +- Pairing helpers: `PAIRING_APPROVED_MESSAGE`, `formatPairingApproveHint`. +- Onboarding helpers: `promptChannelAccessConfig`, `addWildcardAllowFrom`, onboarding types. +- Tool param helpers: `createActionGate`, `readStringParam`, `readNumberParam`, `readReactionParams`, `jsonResult`. +- Docs link helper: `formatDocsLink`. + +Delivery: +- Publish as `@clawdbot/plugin-sdk` (or export from core under `clawdbot/plugin-sdk`). +- Semver with explicit stability guarantees. + +### 2) Plugin Runtime (execution surface, injected) +Scope: everything that touches core runtime behavior. +Accessed via `ClawdbotPluginApi.runtime` so plugins never import `src/**`. + +Proposed surface (minimal but complete): +```ts +export type PluginRuntime = { + channel: { + text: { + chunkMarkdownText(text: string, limit: number): string[]; + resolveTextChunkLimit(cfg: ClawdbotConfig, channel: string, accountId?: string): number; + hasControlCommand(text: string, cfg: ClawdbotConfig): boolean; + }; + reply: { + dispatchReplyWithBufferedBlockDispatcher(params: { + ctx: unknown; + cfg: unknown; + dispatcherOptions: { + deliver: (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }) => + void | Promise; + onError?: (err: unknown, info: { kind: string }) => void; + }; + }): Promise; + createReplyDispatcherWithTyping?: unknown; // adapter for Teams-style flows + }; + routing: { + resolveAgentRoute(params: { + cfg: unknown; + channel: string; + accountId: string; + peer: { kind: "dm" | "group" | "channel"; id: string }; + }): { sessionKey: string; accountId: string }; + }; + pairing: { + buildPairingReply(params: { channel: string; idLine: string; code: string }): string; + readAllowFromStore(channel: string): Promise; + upsertPairingRequest(params: { + channel: string; + id: string; + meta?: { name?: string }; + }): Promise<{ code: string; created: boolean }>; + }; + media: { + fetchRemoteMedia(params: { url: string }): Promise<{ buffer: Buffer; contentType?: string }>; + saveMediaBuffer( + buffer: Uint8Array, + contentType: string | undefined, + direction: "inbound" | "outbound", + maxBytes: number, + ): Promise<{ path: string; contentType?: string }>; + }; + mentions: { + buildMentionRegexes(cfg: ClawdbotConfig, agentId?: string): RegExp[]; + matchesMentionPatterns(text: string, regexes: RegExp[]): boolean; + }; + groups: { + resolveGroupPolicy(cfg: ClawdbotConfig, channel: string, accountId: string, groupId: string): { + allowlistEnabled: boolean; + allowed: boolean; + groupConfig?: unknown; + defaultConfig?: unknown; + }; + resolveRequireMention( + cfg: ClawdbotConfig, + channel: string, + accountId: string, + groupId: string, + override?: boolean, + ): boolean; + }; + debounce: { + createInboundDebouncer(opts: { + debounceMs: number; + buildKey: (v: T) => string | null; + shouldDebounce: (v: T) => boolean; + onFlush: (entries: T[]) => Promise; + onError?: (err: unknown) => void; + }): { push: (v: T) => void; flush: () => Promise }; + resolveInboundDebounceMs(cfg: ClawdbotConfig, channel: string): number; + }; + commands: { + resolveCommandAuthorizedFromAuthorizers(params: { + useAccessGroups: boolean; + authorizers: Array<{ configured: boolean; allowed: boolean }>; + }): boolean; + }; + }; + logging: { + shouldLogVerbose(): boolean; + getChildLogger(name: string): PluginLogger; + }; + state: { + resolveStateDir(cfg: ClawdbotConfig): string; + }; +}; +``` + +Notes: +- Runtime is the only way to access core behavior. +- SDK is intentionally small and stable. +- Each runtime method maps to an existing core implementation (no duplication). + +## Migration plan (phased, safe) + +### Phase 0: scaffolding +- Introduce `@clawdbot/plugin-sdk`. +- Add `api.runtime` to `ClawdbotPluginApi` with the surface above. +- Maintain existing imports during a transition window (deprecation warnings). + +### Phase 1: bridge cleanup (low risk) +- Replace per-extension `core-bridge.ts` with `api.runtime`. +- Migrate BlueBubbles, Zalo, Zalo Personal first (already close). +- Remove duplicated bridge code. + +### Phase 2: light direct-import plugins +- Migrate Matrix to SDK + runtime. +- Validate onboarding, directory, group mention logic. + +### Phase 3: heavy direct-import plugins +- Migrate MS Teams (largest set of runtime helpers). +- Ensure reply/typing semantics match current behavior. + +### Phase 4: iMessage pluginization +- Move iMessage into `extensions/imessage`. +- Replace direct core calls with `api.runtime`. +- Keep config keys, CLI behavior, and docs intact. + +### Phase 5: enforcement +- Add lint rule / CI check: no `extensions/**` imports from `src/**`. +- Add plugin SDK/version compatibility checks (runtime + SDK semver). + +## Compatibility and versioning +- SDK: semver, published, documented changes. +- Runtime: versioned per core release. Add `api.runtime.version`. +- Plugins declare a required runtime range (e.g., `clawdbotRuntime: ">=2026.2.0"`). + +## Testing strategy +- Adapter-level unit tests (runtime functions exercised with real core implementation). +- Golden tests per plugin: ensure no behavior drift (routing, pairing, allowlist, mention gating). +- A single end-to-end plugin sample used in CI (install + run + smoke). + +## Open questions +- Where to host SDK types: separate package or core export? +- Runtime type distribution: in SDK (types only) or in core? +- How to expose docs links for bundled vs external plugins? +- Do we allow limited direct core imports for in-repo plugins during transition? + +## Success criteria +- All channel connectors are plugins using SDK + runtime. +- No `extensions/**` imports from `src/**`. +- New connector templates depend only on SDK + runtime. +- External plugins can be developed and updated without core source access. + +Related docs: [Plugins](/plugin), [Channels](/channels/index), [Configuration](/gateway/configuration). diff --git a/package.json b/package.json index 088771eb0..c8dfd4c7e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dist/media-understanding/**", "dist/process/**", "dist/plugins/**", + "dist/plugin-sdk/**", "dist/security/**", "dist/sessions/**", "dist/providers/**", diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts new file mode 100644 index 000000000..75d63b251 --- /dev/null +++ b/src/plugin-sdk/index.ts @@ -0,0 +1,78 @@ +export { CHANNEL_MESSAGE_ACTION_NAMES } from "../channels/plugins/message-action-names.js"; +export type { + ChannelAccountSnapshot, + ChannelAccountState, + ChannelAgentTool, + ChannelAgentToolFactory, + ChannelAuthAdapter, + ChannelCapabilities, + ChannelCommandAdapter, + ChannelConfigAdapter, + ChannelDirectoryAdapter, + ChannelDirectoryEntry, + ChannelDirectoryEntryKind, + ChannelElevatedAdapter, + ChannelGatewayAdapter, + ChannelGatewayContext, + ChannelGroupAdapter, + ChannelGroupContext, + ChannelHeartbeatAdapter, + ChannelHeartbeatDeps, + ChannelId, + ChannelLogSink, + ChannelLoginWithQrStartResult, + ChannelLoginWithQrWaitResult, + ChannelLogoutContext, + ChannelLogoutResult, + ChannelMentionAdapter, + ChannelMessageActionAdapter, + ChannelMessageActionContext, + ChannelMessageActionName, + ChannelMessagingAdapter, + ChannelMeta, + ChannelOutboundAdapter, + ChannelOutboundContext, + ChannelOutboundTargetMode, + ChannelPairingAdapter, + ChannelPollContext, + ChannelPollResult, + ChannelResolveKind, + ChannelResolveResult, + ChannelResolverAdapter, + ChannelSecurityAdapter, + ChannelSecurityContext, + ChannelSecurityDmPolicy, + ChannelSetupAdapter, + ChannelSetupInput, + ChannelStatusAdapter, + ChannelStatusIssue, + ChannelStreamingAdapter, + ChannelThreadingAdapter, + ChannelThreadingContext, + ChannelThreadingToolContext, + ChannelToolSend, +} from "../channels/plugins/types.js"; +export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js"; + +export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "../channels/plugins/config-helpers.js"; +export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js"; +export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; +export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; + +export type { ChannelOnboardingAdapter } from "../channels/plugins/onboarding-types.js"; +export { addWildcardAllowFrom } from "../channels/plugins/onboarding/helpers.js"; +export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; + +export { + createActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringParam, +} from "../agents/tools/common.js"; + +export { formatDocsLink } from "../terminal/links.js"; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 97ccfcb53..304c58c90 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -6,6 +6,7 @@ import { createSubsystemLogger } from "../logging.js"; import { resolveUserPath } from "../utils.js"; import { discoverClawdbotPlugins } from "./discovery.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; +import { createPluginRuntime } from "./runtime/index.js"; import { setActivePluginRegistry } from "./runtime.js"; import type { ClawdbotPluginConfigSchema, @@ -275,8 +276,10 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi } } + const runtime = createPluginRuntime(); const { registry, createApi } = createPluginRegistry({ logger, + runtime, coreGatewayHandlers: options.coreGatewayHandlers as Record, }); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 81010776c..3689f0261 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -21,6 +21,7 @@ import type { PluginOrigin, PluginKind, } from "./types.js"; +import type { PluginRuntime } from "./runtime/types.js"; export type PluginToolRegistration = { pluginId: string; @@ -100,6 +101,7 @@ export type PluginRegistry = { export type PluginRegistryParams = { logger: PluginLogger; coreGatewayHandlers?: GatewayRequestHandlers; + runtime: PluginRuntime; }; export function createPluginRegistry(registryParams: PluginRegistryParams) { @@ -279,6 +281,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { source: record.source, config: params.config, pluginConfig: params.pluginConfig, + runtime: registryParams.runtime, logger: normalizeLogger(registryParams.logger), registerTool: (tool, opts) => registerTool(record, tool, opts), registerHttpHandler: (handler) => registerHttpHandler(record, handler), diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts new file mode 100644 index 000000000..8fffa415a --- /dev/null +++ b/src/plugins/runtime/index.ts @@ -0,0 +1,96 @@ +import { createRequire } from "node:module"; + +import { chunkMarkdownText, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; +import { hasControlCommand } from "../../auto-reply/command-detection.js"; +import { createInboundDebouncer, resolveInboundDebounceMs } from "../../auto-reply/inbound-debounce.js"; +import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; +import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention } from "../../config/group-policy.js"; +import { resolveStateDir } from "../../config/paths.js"; +import { shouldLogVerbose } from "../../globals.js"; +import { getChildLogger } from "../../logging.js"; +import { fetchRemoteMedia } from "../../media/fetch.js"; +import { saveMediaBuffer } from "../../media/store.js"; +import { buildPairingReply } from "../../pairing/pairing-messages.js"; +import { readChannelAllowFromStore, upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; +import { resolveAgentRoute } from "../../routing/resolve-route.js"; + +import type { PluginRuntime } from "./types.js"; + +let cachedVersion: string | null = null; + +function resolveVersion(): string { + if (cachedVersion) return cachedVersion; + try { + const require = createRequire(import.meta.url); + const pkg = require("../../../package.json") as { version?: string }; + cachedVersion = pkg.version ?? "unknown"; + return cachedVersion; + } catch { + cachedVersion = "unknown"; + return cachedVersion; + } +} + +export function createPluginRuntime(): PluginRuntime { + return { + version: resolveVersion(), + channel: { + text: { + chunkMarkdownText, + resolveTextChunkLimit, + hasControlCommand, + }, + reply: { + dispatchReplyWithBufferedBlockDispatcher, + createReplyDispatcherWithTyping, + }, + routing: { + resolveAgentRoute, + }, + pairing: { + buildPairingReply, + readAllowFromStore: readChannelAllowFromStore, + upsertPairingRequest: upsertChannelPairingRequest, + }, + media: { + fetchRemoteMedia, + saveMediaBuffer, + }, + mentions: { + buildMentionRegexes, + matchesMentionPatterns, + }, + groups: { + resolveGroupPolicy: resolveChannelGroupPolicy, + resolveRequireMention: resolveChannelGroupRequireMention, + }, + debounce: { + createInboundDebouncer, + resolveInboundDebounceMs, + }, + commands: { + resolveCommandAuthorizedFromAuthorizers, + }, + }, + logging: { + shouldLogVerbose, + getChildLogger: (bindings, opts) => { + const logger = getChildLogger(bindings, opts); + return { + debug: (message) => logger.debug?.(message), + info: (message) => logger.info(message), + warn: (message) => logger.warn(message), + error: (message) => logger.error(message), + }; + }, + }, + state: { + resolveStateDir, + }, + }; +} + +export type { PluginRuntime } from "./types.js"; diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts new file mode 100644 index 000000000..39ef343ac --- /dev/null +++ b/src/plugins/runtime/types.ts @@ -0,0 +1,103 @@ +import type { ClawdbotConfig } from "../../config/config.js"; + +export type RuntimeLogger = { + debug?: (message: string) => void; + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; +}; + +export type PluginRuntime = { + version: string; + channel: { + text: { + chunkMarkdownText: (text: string, limit: number) => string[]; + resolveTextChunkLimit: (cfg: ClawdbotConfig, channel: string, accountId?: string) => number; + hasControlCommand: (text: string, cfg: ClawdbotConfig) => boolean; + }; + reply: { + dispatchReplyWithBufferedBlockDispatcher: (params: { + ctx: unknown; + cfg: unknown; + dispatcherOptions: { + deliver: (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }) => void | Promise; + onError?: (err: unknown, info: { kind: string }) => void; + }; + }) => Promise; + createReplyDispatcherWithTyping: (...args: unknown[]) => unknown; + }; + routing: { + resolveAgentRoute: (params: { + cfg: unknown; + channel: string; + accountId: string; + peer: { kind: "dm" | "group" | "channel"; id: string }; + }) => { sessionKey: string; accountId: string }; + }; + pairing: { + buildPairingReply: (params: { channel: string; idLine: string; code: string }) => string; + readAllowFromStore: (channel: string) => Promise; + upsertPairingRequest: (params: { + channel: string; + id: string; + meta?: { name?: string }; + }) => Promise<{ code: string; created: boolean }>; + }; + media: { + fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>; + saveMediaBuffer: ( + buffer: Uint8Array, + contentType: string | undefined, + direction: "inbound" | "outbound", + maxBytes: number, + ) => Promise<{ path: string; contentType?: string }>; + }; + mentions: { + buildMentionRegexes: (cfg: ClawdbotConfig, agentId?: string) => RegExp[]; + matchesMentionPatterns: (text: string, regexes: RegExp[]) => boolean; + }; + groups: { + resolveGroupPolicy: ( + cfg: ClawdbotConfig, + channel: string, + accountId: string, + groupId: string, + ) => { + allowlistEnabled: boolean; + allowed: boolean; + groupConfig?: unknown; + defaultConfig?: unknown; + }; + resolveRequireMention: ( + cfg: ClawdbotConfig, + channel: string, + accountId: string, + groupId: string, + override?: boolean, + ) => boolean; + }; + debounce: { + createInboundDebouncer: (opts: { + debounceMs: number; + buildKey: (value: T) => string | null; + shouldDebounce: (value: T) => boolean; + onFlush: (entries: T[]) => Promise; + onError?: (err: unknown) => void; + }) => { push: (value: T) => void; flush: () => Promise }; + resolveInboundDebounceMs: (cfg: ClawdbotConfig, channel: string) => number; + }; + commands: { + resolveCommandAuthorizedFromAuthorizers: (params: { + useAccessGroups: boolean; + authorizers: Array<{ configured: boolean; allowed: boolean }>; + }) => boolean; + }; + }; + logging: { + shouldLogVerbose: () => boolean; + getChildLogger: (bindings?: Record, opts?: { level?: string }) => RuntimeLogger; + }; + state: { + resolveStateDir: (cfg: ClawdbotConfig) => string; + }; +}; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index ecd4425cd..259aa7616 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -11,6 +11,9 @@ import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { createVpsAwareOAuthHandlers } from "../commands/oauth-flow.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; +import type { PluginRuntime } from "./runtime/types.js"; + +export type { PluginRuntime } from "./runtime/types.js"; export type PluginLogger = { debug?: (message: string) => void; @@ -164,6 +167,7 @@ export type ClawdbotPluginApi = { source: string; config: ClawdbotConfig; pluginConfig?: Record; + runtime: PluginRuntime; logger: PluginLogger; registerTool: ( tool: AnyAgentTool | ClawdbotPluginToolFactory,