插件 SDK + 运行时重构计划

插件 SDK + 运行时重构计划

适用范围

在以下情况使用此页面:

  • 定义或重构插件架构
  • 将频道连接器迁移到插件 SDK/运行时

目标:每个消息连接器都是使用一个稳定 API 的插件(捆绑或外部)。没有插件直接从 src/** 导入。所有依赖都通过 SDK 或运行时。

为什么现在

  • 当前的连接器混合模式:直接核心导入、仅 dist 桥接和自定义助手。
  • 这使升级变得脆弱,并阻碍了干净的外部插件表面。

目标架构(两层)

1) 插件 SDK(编译时、稳定、可发布)

范围:类型、助手和配置实用程序。无运行时状态,无副作用。

内容(示例):

  • 类型:ChannelPlugin、adapters、ChannelMetaChannelCapabilitiesChannelDirectoryEntry
  • 配置助手:buildChannelConfigSchemasetAccountEnabledInConfigSectiondeleteAccountFromConfigSectionapplyAccountNameToChannelSection
  • 配对助手:PAIRING_APPROVED_MESSAGEformatPairingApproveHint
  • 入门助手:promptChannelAccessConfigaddWildcardAllowFrom、入门类型。
  • 工具参数助手:createActionGatereadStringParamreadNumberParamreadReactionParamsjsonResult
  • 文档链接助手:formatDocsLink

交付:

  • 发布为 @clawdbot/plugin-sdk(或从核心导出为 clawdbot/plugin-sdk)。
  • Semver,具有明确的稳定性保证。

2) 插件运行时(执行表面、注入)

范围:接触核心运行时行为的所有内容。 通过 ClawdbotPluginApi.runtime 访问,因此插件永不导入 src/**

拟议表面(最小但完整):

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<void>;
          onError?: (err: unknown, info: { kind: string }) => void;
        };
      }): Promise<void>;
      createReplyDispatcherWithTyping?: unknown; // Teams 风格流程的适配器
    };
    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<string[]>;
      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<T>(opts: {
        debounceMs: number;
        buildKey: (v: T) => string | null;
        shouldDebounce: (v: T) => boolean;
        onFlush: (entries: T[]) => Promise<void>;
        onError?: (err: unknown) => void;
      }): { push: (v: T) => void; flush: () => Promise<void> };
      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;
  };
};

注意事项:

  • 运行时是访问核心行为的唯一方式。
  • SDK 故意小而稳定。
  • 每个运行时方法映射到现有的核心实现(无重复)。

迁移计划(分阶段、安全)

阶段 0:脚手架

  • 引入 @clawdbot/plugin-sdk
  • 使用上述表面向 ClawdbotPluginApi 添加 api.runtime
  • 在过渡窗口期间保持现有导入(弃用警告)。

阶段 1:桥接清理(低风险)

  • 使用 api.runtime 替换每个扩展的 core-bridge.ts
  • 首先迁移 BlueBubbles、Zalo、Zalo Personal(已经接近)。
  • 移除重复的桥接代码。

阶段 2:轻量级直接导入插件

  • 将 Matrix 迁移到 SDK + 运行时。
  • 验证入门、目录、组提及逻辑。

阶段 3:重量级直接导入插件

  • 迁移 MS Teams(最大的运行时助手集)。
  • 确保回复/输入语义与当前行为匹配。

阶段 4:iMessage 插件化

  • 将 iMessage 移动到 extensions/imessage
  • 使用 api.runtime 替换直接核心调用。
  • 保持配置键、CLI 行为和文档完整。

阶段 5:强制执行

  • 添加 lint 规则 / CI 检查:没有从 src/** 导入 extensions/**
  • 添加插件 SDK/版本兼容性检查(运行时 + SDK semver)。

兼容性和版本控制

  • SDK:semver,已发布,已记录的更改。
  • 运行时:按核心发布版本化。添加 api.runtime.version
  • 插件声明所需的运行时范围(例如,clawdbotRuntime: ">=2026.2.0")。

测试策略

  • 适配器级单元测试(使用真实核心实现练习运行时函数)。
  • 每个插件的黄金测试:确保没有行为漂移(路由、配对、允许列表、提及门控)。
  • CI 中使用的单个端到端插件示例(安装 + 运行 + 冒烟)。

未解决的问题

  • 在哪里托管 SDK 类型:单独的包还是核心导出?
  • 运行时类型分发:在 SDK 中(仅类型)还是在核心中?
  • 如何为捆绑与外部插件公开文档链接?
  • 我们是否允许在过渡期间为仓库内插件进行有限的直接核心导入?

成功标准

  • 所有频道连接器都是使用 SDK + 运行时的插件。
  • 没有从 src/** 导入 extensions/**
  • 新连接器模板仅依赖 SDK + 运行时。
  • 外部插件可以在没有核心源访问的情况下开发和更新。

相关文档:插件频道配置