dsh-WeCom-notify 开发经验(Development Notes)

August 15, 2026 · View on GitHub

本文沉淀 v0.3.0(设置面板 + 多 webhook key 同时通知)开发过程中的关键知识, 尤其是「DSH 插件 client 设置面板」的正确姿势与踩坑记录。 面向后续维护者与想给 dsh 写「设置页插件」的开发者。


1. 插件 client 设置面板的正确姿势(本次最大坑)

dsh 的「设置」页(设置 → 各 section)由 settings.section 插槽承载: kind: 'list'scope: 'root'(契约见 @deepseek-ai/dsh-client-ui-settingscontract/slots.d.ts)。

注册契约:component 是 React 组件,且必须是 register() 的第二参数:

ctx.slots.inject('settings.section', () =>
  ctx.slots.register(
    {
      name: 'settings.section',
      id: 'wecom-notify',          // section 导航 key
      order: 40,                   // 导航排序
      label: () => '企业微信通知',   // 显示名(可函数跟随 locale)
    },
    WecomNotifyPanel,              // ⚠️ React 组件作第二参数!
  ),
)

❌ 错误写法(曾导致面板空白):照抄 dsh-super-injector 的

ctx.slots.register({ ..., component: () => ({ render() {...} }) })  // 单参数!

两个问题叠加:

  1. SlotRegistry.register(rawOptions, component) 的组件在第二参数;单参调用时 component 恒为 undefined
  2. 即便传入,() => ({ render() }) 返回普通对象,也不是合法 ReactNode。

web-react renderer 渲染时执行 jsx(Comp, props),失败被 SlotErrorBoundary 吞掉 → 导航项「企业微信通知」正常显示(label 在 options 里),内容区空白。诊断要点: 导航 label 出现 ≠ 组件渲染成功。

佐证:官方 dsh-client-ui-settings-pluginsPluginsSettingsSection 就是 register(options, ReactComponent) 形态。super-injector 的「插件」面板用的 错误写法,实际同样空白。

命令式 DOM 逻辑保留方案(不必重写成全 React):React 组件外壳 + useEffect 挂载:

function WecomNotifyPanel() {
  const hostRef = useRef<HTMLDivElement>(null)
  useEffect(() => {
    const root = hostRef.current
    if (!root) return
    const page = buildPage()          // 原有 document.createElement 构建逻辑
    root.appendChild(page)
    return () => { root.textContent = '' }
  }, [])
  return createElement('div', { ref: hostRef })
}

client bundle 可直接 import { useEffect, useRef } from 'react'(tsdown 保持 require("react") external,运行时由 dsh web 提供——官方插件同款)。

2. client 面板必须「包名装配」,file:// 入口带不上

浏览器端 client 模块由 @deepseek-ai/dsh-client-modules 发现:它扫描 host Loader entries 中声明了 dsh.client 的包entry.options.name 必须等于包名, 且有活跃 fiber 且未 disabled),读取 package.json

  • dsh.client: { platform: 'web', inject: [...] } → 进入 manifest
  • exports["./client"] → 定位 lib/client.js 路径
  • manifest 的 id = 包名,浏览器加载 /plugins/<包名>/client.js

因此:

  • ❌ patch 挂载 name: 'file:///.../lib/index.js':entry 不是包 → 永远没有 client 面板(host 功能完整,仅无 UI)。
  • ✅ 包名装配:junction 链接到 profile node_modules + bundles/dependencies 声明 (见 README「方式 A」)。cordis.patch.yml 里 entry 的 name 也要写包名。

ModuleLoader.load({ id: '包名', ... }) 的 id 必须与包名一致(tsdown banner 里的 PLUGIN_ID 就是包名),否则 manifest id 与注册 id 对不上。

3. tsdown client bundle 模板

// tsdown.config.ts
const clientBundle: UserConfig = {
  entry: { client: 'src/client/index.ts' },
  outDir: 'lib',
  format: 'cjs',
  platform: 'browser',
  dts: false,
  clean: false,
  deps: {
    neverBundle: ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client',
      'cordis', '@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-runtime/client'],
    alwaysBundle: (id: string) => !CLIENT_EXTERNALS.includes(id),
  },
  outputOptions: {
    entryFileNames: 'client.js',
    banner: 'window.__ModuleLoader__.load({ id: ' + JSON.stringify(PLUGIN_ID) + ', factory: (require) => {',
    footer: 'return module.exports; } });',
    intro: 'var module = { exports: {} }; var exports = module.exports;',
    codeSplitting: false,
  },
}
  • 产物是 CJS + ModuleLoader banner(module.exports 里应有 injectapply)。
  • 类型检查:host 的 tsconfig exclude: ["src/client"];client 单独 tsconfig.client.jsonlib: ["es2022", "dom"]types: [])。react 类型 解析依赖构建时的 junction link,缺失时 typecheck fails-soft(不影响产物)。

4. host 侧 webServer API

export const inject = ['tools', 'webServer']   // 声明服务

ctx.effect(() => ctx.webServer.register({
  kind: 'prefix',
  path: '/wecom-notify/api',
  handler: async (req, res) => { /* ... */ },
}), 'wecom-notify: settings-api')
  • register 返回 disposer(删除路由),配合 ctx.effect 卸载即清理。
  • req/res 是 Node http 的 IncomingMessage/ServerResponse;读 body 用 for await (const c of req)
  • 浏览器端同源 fetch('/wecom-notify/api/config')(相对路径)即可。
  • ctx.webServer 不在 @deepseek-ai/cordis 基础类型里:declare module '@deepseek-ai/cordis' { interface Context { webServer: ... } } 声明合并。

5. 配置设计:持久化文件 > cordis config > 环境变量

  • 设置面板保存 → <DSH_HOME>/wecom-notify/config.json(0600,含密钥)。 DSH_HOME 解析:process.env.DSH_HOME || ~/.dsh
  • 文件存在时整体优先(保存的是完整生效快照,面板里清空 = 显式清空); 删文件即回到静态配置。启动时 applyStored(loadConfig(config), readStoredConfig())
  • 热更新:notifier 持有可变配置引用,updateConfig() 整体替换—— 保存即生效,无需重启(配置 API 与发送调度解耦)。
  • 兼容旧字段:单 webhookKey/WECHAT_WEBHOOK_KEY 自动并入 webhookKeys 数组。

6. 开发环境隔离(DSH_HOME)

全局 profile 动不得时,用独立开发环境:

DSH_HOME=/path/to/.dsh-dev dsh web --port 3081   # 与全局 3080 错开
  • .dsh-dev 里建 profiles/web/(package.json + cordis.patch.yml), 装配方式与正式 profile 完全一致,但全部落在工作区,不污染 ~/.dsh
  • 验证链:curl /wecom-notify/api/config(GET/POST)→ 真实发送 → 重启看持久化 source: "file"curl /plugins/<包名>/client.js(面板资源 200)。

7. cordis.patch.yml 结构(loader patch 语义)

顶层必须是 YAML 数组[] 合法空态),元素两类:

# ① insert:插入新条目(无 id 则追加到顶层列表)
- insert:
    - id: wechat-notify
      name: 'dsh-wecom-notify'      # ⚠️ bundle patch 里必须写包名!
      config: {}
# ② 按 id 覆盖 / 禁用(applyEntryPatches:定位 → 合并字段;name 可做校验)
- id: wechat-notify
  disabled: true
  • 语义真源:dsh-app-bootapplyEntryPatches(id 定位、insert 追加、name 不匹配跳过、匹配不到警告)。
  • bundle patch(插件自带 cordis.patch.yml)里 entry name 写相对路径是错的: 相对路径基于 profile 目录解析(<profile>/lib/index.js)——必须写包名, 由 profile node_modules 的 junction/依赖解析。
  • disabled 支持 !!js 表达式(可对 env 求值)。

8. 工具链教训

现象对策
dev_uninject_plugin 会改写 profile patch曾把 - insert: 与子条目压成一行 → YAML 解析崩溃,dsh 命令全挂动 patch 前先备份;卸载走完立刻核对文件;损坏就恢复备份
npm install 404(tsdown 传递依赖 @deepseek-ai/dsh-compact 未发布)装不上 tsdownbuild.sh 自动回退 DSH_CHECKOUT/node_modules/.bin/tsdown
Node ≥22.6 直跑 .ts(node --test / scripts)a ?? b || c 报 ERR_INVALID_TYPESCRIPT_SYNTAX??|| 混用必须加括号
事件监听器抛异常会炸事件循环插件崩溃监听器体包 try/catch,永不抛出
client 类型漂移(SlotsService 等)依赖包版本升级即破按使用面自写结构类型,构建不校验类型、运行时以实际服务为准

9. 验收清单(改完插件跑一遍)

npm test                     # 51 用例
npm run typecheck            # host 类型
DSH_CHECKOUT=<dsh仓库> npm run build   # host + client + client typecheck(fails-soft)

# 开发实例验证
DSH_HOME=<dev> dsh web --port 3081
curl <dev>/wecom-notify/api/config                      # GET 生效配置
curl -X POST <dev>/wecom-notify/api/config -d '{...}'   # 保存(热生效 + 持久化)
curl -X POST <dev>/wecom-notify/api/test -d '{...}'     # 真实发送,逐群结果
curl <dev>/plugins/<>/client.js                     # 面板资源 200
# 重启实例 → GET /config 应显示 source: "file"(持久化恢复)