DeepSeek Harness Plugin Hub

发布与管理完整 Harness Profiles,发现适合你的插件。

探索

插件目录环境预设文档中心动态

社区

发布插件联系我们报告问题

相关链接

Plugin Hub GitHubDeepSeek Harness 官方项目系统状态隐私说明
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

独立、非官方社区项目,与 DeepSeek 官方无隶属、授权或背书关系。

DeepSeek Harness Plugin Hub
ProfilesPlugins分类动态文档登录管理 Profiles
ProfilesPlugins分类动态文档登录
← Plugins

dsh-plugin-tlnotify

Plugin Tlnotify

把 DSH 会话事件(中断 / 结束 / 报错 / 等待操作)聚合到一个 IM 通道,推到 QQ / 飞书,带可点按钮与定向回复

插件会安装到这里;不确定时保持 web。

npx -y @deepseek-ai/dsh plugin --profile web add dsh-plugin-tlnotify@1.4.0
README兼容性版本

兼容性与来源证明

Plugin Tlnotify 以 dsh-plugin-tlnotify 发布,当前版本为 1.4.0。Plugin Hub 会校验它的 manifest,并保存精确安装来源,便于复现安装结果。

DSH 兼容范围
*
运行环境
web
发布来源
npm
Registry 更新时间
2026/11/4

版本

1.4.0stable
2026/11/2
1.0.0stable
2026/10/19
0.2.0stable
2026/10/4
查看其余 9 个版本收起版本
0.1.8stable
2026/10/4
0.1.7stable
2026/10/4
0.1.6stable
2026/10/3
0.1.5stable
2026/10/2
0.1.4stable
2026/10/2
0.1.3stable
2026/10/2
0.1.2stable
2026/10/1
0.1.1stable
2026/10/1
0.1.0stable
2026/10/1
最新版
1.4.0
DSH
*
HMR
重启进程
Tree shaking
未声明可安全裁剪
解包体积
2.5 MB
文件数
12
Surface
web
许可证
MIT
发布源
npm
GitHub
★ 0
周下载
350
安全扫描
✓ v1.4.0 扫描通过
最近提交
2026/11/2
查看源码 ↗项目主页 ↗
README Badge

点击下方 Badge 复制 Markdown,粘贴到 README 即可。

这是你的 Plugin?认领权益 · 优先安全扫描

验证 package.json 声明的 GitHub 仓库,即可管理这个公开页面。认领后,Hub 会优先安排当前版本的安全扫描,并在通过后公开展示结果。

认领这个 Plugin →
报告问题

README

dsh-plugin-tlnotify

把 所有 DeepSeek Harness 会话的事件聚合到一个 IM 通道,并且能回话。

DSH 跑在电脑上,人不在电脑前。tlnotify 把「哪个项目的哪个对话」出了什么事推到你的 手机(QQ 单聊机器人 / 飞书自建应用),并且让你直接在那条消息下面回复,内容会注入 回正确的会话——不用回电脑,也不用在浏览器里翻找那个标签页。

1.0.0 起适配 DSH 0.2.1-rc.2:设置页「通知助手」面板修好了在 0.2.1 客户端沙箱下的白屏 (ctx.connection 直接取会抛,改走 ctx.get('connection')),并补上 external: ["react"]。 不配任何机器人也能正常加载,设置页会显示「通道 0/0」。


✨ 核心特性

  • 一个通道看全部会话。标题三段式:DSH · <项目目录名> · <短会话id> · <事件>, 例 DSH · my-dsh-plugins · 519cc141 · 权限请求——一眼看出是哪个项目的哪个对话。
  • 九类事件:任务完成、执行错误、执行被阻塞、手动中止、Token 达到上限、异常中断、 等待我回答、权限请求、等待计划确认。
  • 能点按钮。审批给「允许 / 拒绝」,提问给选项按钮(多选另有「提交所选」),计划确认 给「批准计划 / 不批准」,另外附「会话短id」「详细模式」两个操作按钮。点击即在 DSH 侧生效。
  • 能定向回复。三层路由(按钮 → 长按引用回复 → 显式短 id 前缀)保证消息回到正确的会话; 没有引用时兜底发给最新一条通知的会话,并且一定回显「已发给 X」。
  • 零公网依赖。两个通道都走 WebSocket 长连接,不需要公网 IP、不需要域名、不需要内网穿透。
  • 每台机器人各自订阅。会话范围是每台机器人自己的:关心全部会话 / 只关心一个 会话(带完整上下文与前 N 轮摘要)/ 只关心名单里的若干个。互不影响——一台设成单会话, 另一台照样收全局。投递是逐台的:谁勾了谁收到,两台都勾同一会话就各收一条。
  • 不刷屏。完成门控(等 agent 真正空闲再发)、按 会话:seq 去重(24 小时窗口)、 正文聚合、长文本自动分片(按钮只在首片)。
  • 重启不丢。路由表、每台机器人的会话范围、详细模式名单都落盘,TTL 默认 7 天。

📦 安装

本包是 bundle 形态(package.json 的 dsh.bundle.patch 指向包内自带的 cordis.patch.yml)。因此只要被列进 profile 的 dsh.profile.bundles 就会自动激活, 不需要再往 profile 的 cordis.patch.yml 写挂载条目。

方式一:一条命令安装并激活(推荐)

dsh plugin --profile web add dsh-plugin-tlnotify

dsh plugin add 会自动装依赖并把该包归并进 dsh.profile.bundles(幂等)。 dsh plugin --profile web list 可核对激活状态。

方式二:手工安装(等价)

cd ~/.dsh/profiles/web
pnpm add dsh-plugin-tlnotify

然后把它加进 ~/.dsh/profiles/web/package.json 的 dsh.profile.bundles 数组:

"dsh": { "profile": { "bundles": ["…", "dsh-plugin-tlnotify"] } }

调试本地源码时依赖写成 "dsh-plugin-tlnotify": "file:D:/Code/my-dsh-plugins/packages/tlnotify", 并且同样必须出现在 dsh.profile.bundles 里才会生效——只装依赖不激活是不会生效的。

要接飞书通道,还需要在 profile 里自己装上飞书官方 SDK: pnpm add @larksuiteoapi/node-sdk。只接 QQ 则不需要。

它故意不在本插件的 package.json 里声明:该包解包后约 30 MB,做成自动安装的 依赖会让只用 QQ 的用户白白付出下载成本。插件在飞书通道启动时才动态 import() 它, 没装的话只有飞书通道启动失败并打印上面这条安装指引,QQ 通道与插件装载都不受影响。


🚀 快速开始(QQ,约 10 分钟)

QQ 单聊机器人是首选通道:长连接收事件、支持按钮、支持长按引用回复, 而且个人可以自助注册,不需要企业资质。

  1. 注册机器人:打开 https://q.qq.com/ → 创建机器人 → 拿到 AppID 与 AppSecret。

  2. 打开消息权限:在「开发设置」里开启 C2C 消息(单聊)相关权限; 如果用群聊,还要开启群 @ 消息。

  3. 拿到你自己的 openid:机器人上线后,用你自己的 QQ 给机器人发一条消息, 插件日志里会出现一条 收到入站消息 … sender=<你的 openid>。 把它填进配置的 targetChatId。

  4. 配置通道:打开 DSH 的设置页(设置 → 插件 → 通知助手)→「通道」→ 添加一个 qq 通道,把 AppID / AppSecret / targetChatId 填进去;也可以直接改 $DSH_HOME/tlnotify/config.json:

    {
      "enabled": true,
      "mode": "global",
      "channels": [
        {
          "id": "qq-main",
          "type": "qq",
          "enabled": true,
          "appId": "102xxxxxx",
          "appSecret": "xxxxxxxxxxxxxxxx",
          "targetChatId": "你的 openid"
        }
      ],
      "defaultChannelId": "qq-main"
    }
    
  5. 重启 DSH,然后在 QQ 里给机器人发 /mode 验证双向通路。

⚠️ 唯一的硬限制:QQ 用户可以在客户端设置里关闭「允许主动发送消息」。 关闭后所有主动推送都会失败(按钮回调后的回复属于被动回复,仍可用)。 插件检测到这种情况会在日志里给出明确提示。


🔌 通道

QQ 单聊机器人(type: "qq")

配置项必填说明
appId✅机器人 AppID(「扫码接入机器人」会自动填好)
appSecret✅机器人 AppSecret(.role('secret'),只存在本机配置里)
targetChatId✅目标用户的 openid。每个机器人看到的 openid 不同,换机器人要重新获取
groupChatId填了就发到群,而不再发单聊
markdown默认关。开了才用 QQ 原生 Markdown 卡片(msg_type: 2),关着发纯文本
modeactive(默认,主动推送)/ passive(只走被动回复窗口)
bindUrl机器人的分享链接,「扫码绑定」用它生成二维码
sessionFilter会话白名单:填了就只推这些会话;留空推全部
  • 主动消息限额:1000 条/天/用户、20 条/分钟。被动回复窗口 60 分钟、最多 4 次。
  • 正文默认走纯文本(msg_type: 0 + content);把通道的 markdown 打开才走 原生 Markdown(msg_type: 2 + markdown.content),标题加粗并与正文空一行。 纯文本与 Markdown 是互斥的两种载荷——发 Markdown 时再带一个外层 content,QQ 会 按纯文本处理(甚至报错),而宿主侧完全静默。载荷形状由 buildSendPayload() 决定, 有单测钉住(tests/qq-send.spec.ts);「这次要不要用卡片」由 applyMarkdownPolicy() 决定。机器人需要有 Markdown 权限(官方说法是需审核通过)。
  • ⚠️ 默认关是有原因的:QQ 把 msg_type: 2 的消息画在一张固定宽度的卡片里, 不随窗口自适应。2026-10-04 在同一台电脑、同一个最大化窗口(2094px 宽)里实测: Markdown 卡片气泡右边界约 600px,而同一窗口里普通文本气泡约 850px ——手机上看着正好的通知,在电脑上就像「只有手机宽」,右侧空一半。插件侧没有任何 办法把卡片撑宽(正文里放超长行也不行),所以只能不发卡片。想要加粗标题就打开 markdown,想要和普通消息一样的观感就保持关闭(默认)。
  • 按钮通过 keyboard 实现;action.data 是字符串,所以结构化 value 用 JSON.stringify 承载。
  • ⚠️ 按钮的 action.type 必须是 1(回调)。type: 2 是「指令按钮」,它只会把 data 插进输入框当普通文本发出去,永远不会触发 INTERACTION_CREATE——按钮会变成 「点了没反应」,而且宿主侧完全静默。permission: { type: 2 } 是另一个字段(所有人可点), 不要和它混起来。
  • ⚠️ 按钮只在手机 QQ 的新版本里渲染;桌面端和旧版会显示 unsupport_tips。所以真正的 兜底入口是回文字(见上面的「等待中的提问 / 审批」),正文里也会带上作答提示。
  • 引用回复靠 msg_idx 而不是 message_id:QQ 的入站事件里没有 message_reference 字段, 被引用消息由 message_scene.ext 的 ref_msg_idx(或 message_type===103 时的 msg_elements[0].msg_idx)还原。因此插件同时维护 byRefIdx 与 byContent(正文哈希) 两张反查表——只靠 message_id 是接不上引用回复的。

飞书自建应用(type: "feishu")

配置项必填说明
feishuAppId✅自建应用 App ID
feishuAppSecret✅自建应用 App Secret
feishuReceiveId✅接收消息的 open_id / chat_id / union_id
feishuReceiveIdTypeopen_id(默认)/ chat_id / user_id / union_id / email
sessionFilter同上
  1. 在 https://open.feishu.cn/ 创建企业自建应用,开启机器人能力。
  2. 事件订阅方式选「长连接」——这是免公网的关键,不需要填任何回调地址。
  3. 订阅事件:im.message.receive_v1(收消息)、card.action.trigger(卡片按钮)。
  4. 权限:im:message、im:message:send_as_bot。
  5. 把应用发布到自己的企业/团队,然后把机器人拉进一个会话或直接单聊它。
  • 飞书按钮的 value 可以是任意 JSON 对象,不需要字符串化。
  • 引用回复用 parent_id ?? root_id 反查。
  • update(messageId, patch) 在飞书上是真正可用的(im.message.patch), 所以点完按钮后消息会被原地改写成「已结算」状态;QQ 上不支持编辑, 插件会改为发送一条回执。

每个通道都有的字段(三层独立设置)

通道之间互不影响:凭据是各自的,通知规则与关心的会话也可以各自决定。

配置项默认值说明
enabled新建时 false该通道的开关。关掉的通道不建连、也不参与推送
label''给这台机器人起的名字,只影响界面显示(配置里的键仍是 id)
sessionScopeallall 关心全局(所有会话)/ single 只关心 sessionId 那一个 / filter 只关心 sessionFilter 里列的会话
sessionId''这台机器人在「只关心一个会话」档下绑定的会话(完整 id)。留空 = 谁都不推:宁可安静,也不要突然把全部会话刷过去
sessionFilter[]会话白名单,仅 sessionScope: "filter" 时生效
historyTurns跟随全局这条通知带该会话最近几轮提问:0 = 不带、N = 最近 N 轮(上限 20)、省略 = 跟随全局 session.context.previousTurns
overrideEventsfalsefalse = 事件开关跟随全局;true = 用本通道自己的 events
events—本通道的事件开关,仅 overrideEvents: true 时生效
overrideContentfalsefalse = 正文设置跟随全局;true = 用本通道自己的 content
content—本通道的正文设置,仅 overrideContent: true 时生效

events / content 的字段与顶层同名(见下面的「配置」一节)。 手改 JSON 时注意:把 events: null 写回去表示回到跟随全局, 不是「关掉全部事件」。

historyTurns 是三态:不写这个键才是「跟随全局」,写 0 是「这台就是不带历史」。 设置页的「跟随全局 / 自定义」两档对应的就是这两种写法。 它不挂在 overrideContent 下面——关掉正文自定义不该顺手把历史也关掉。

会话白名单里的 id 必须是完整会话 id(519cc141-… 那样的 UUID,不是前 8 位): 宿主拿事件里的完整 id 去比对,短 id 永远匹配不上。设置页的「会话过滤」页签会列出 最近用过的会话(标题 · 项目 · 最近活动),点选即可,不用手抄 id。


⚙️ 配置

配置有三条路径,权威性从高到低:

  1. Web 设置页(推荐,见下面「设置页(GUI)」一节)——侧边栏 →「设置」→ 「通知助手」,改完即时热生效,不需要重启,也不需要手改文件。
  2. $DSH_HOME/tlnotify/config.json(字段见下)——设置页写的就是这份文件, 两者永远一致;习惯手改的可以直接编辑它。
  3. profile 的 ~/.dsh/profiles/web/cordis.patch.yml 里按 id 覆盖 (注意是覆盖,不是再插一条 - insert:——挂载已由 bundle 完成,重复插入会挂载两次):
- id: dsh-plugin-tlnotify
  config:
    enabled: true
    channels: []

权威性规则:显式配置 > $DSH_HOME/tlnotify/config.json > 内置默认值。 唯一的例外是每台机器人各自的 sessionScope / sessionId——它们是运行时权威: 在 IM 里发 /mode 写入的值会盖过配置文件 / 补丁,否则用户改完范围一重启就丢。 (mode 与 session.targetSessionId 是 0.1.4 之前的全局开关,如今只剩兼容读写, /mode 不再写它们。)

⚠️ 「显式配置」的判据是「与内置默认值不同」,不是「这个键在不在」。 宿主装载插件时会先跑一遍本插件的 Config schema,schemastery 会把没写的字段 逐个填成默认值(cordis 的 resolveConfig() 就一句 config = schema(config)), 所以 apply() 收到的对象里每个键都在——靠「键存在」根本判断不出用户写没写。

推论:在补丁 / GUI 表单里显式写一个恰好等于默认值的值(比如把 maxBodyChars 写成 1800)等同于没写,设置页里的值继续生效;这是解析后的配置里无法分辨的一种 情况,按「用户最后在设置页改成的值才是他的意图」处理。反过来,任何与默认值不同 的值都稳定地盖过设置页,重启也丢不了。

这条判据是修线上故障时补上的:插件设置页把「正文上限(字符)」设成 4000,下次打开 DSH 又变回 1800,连 config.json 里都被写成了 1800——宿主那份「全是默认值」的配置 被当成了用户意图,启动时的回写又把默认值固化进了文件。tests/config.spec.ts 与 tests/install-rpc.spec.ts 现在锁着这两步。

顶层

配置项默认值说明
enabledtrue总开关。false 时不连任何通道
modeglobal已弃用:0.1.4 起「推哪些会话」由每台机器人各自的 sessionScope 决定,这里只留给老配置读取
channels[][]通道列表,见上
defaultChannelId''默认通道;留空用第一个启用的通道
logLevelinfodebug / info / warn / error
dataDir$DSH_HOME/tlnotify配置 / 状态 / 日志的落盘目录

events —— 哪些事件要推

配置项默认值说明
onTurnEndtrue任务完成
onErrortrue执行错误;同时控制「执行被阻塞」与「异常中断」
onAbortedtrue手动中止
onPendingtrue等待我回答 / 权限请求 / 等待计划确认
onMaxTokenstrueToken 达到上限
includeSubagentfalse是否也推子 Agent 的事件。默认 false——子 Agent 一多会刷屏

content —— 正文放什么

配置项默认值说明
includeMetadatatrue是否带耗时 / 工具数 / token 等元信息
includeUserPromptfalse是否回显触发这一轮的提问(只管通知正文;session.context.includeUserPrompt 是另一回事,默认 true)
maxBodyChars1800正文硬上限,超出截断并标注。设置页里改的值会稳定留存(见上面那条判据)

正文出两版,同一个事件渲染两遍。 助手回复、计划、提问选项、工具报错这些自由文本 会先过一遍 src/markdown.ts 的 toPlainText() 得到纯文本 body(标题 ## X → 【X】,表格 | a | b | → · a:b,列表 - x → · x,加粗 / 斜体 / 删除线 / 链接 / 行内代码去掉标记留文字,围栏代码块内容原样保留、只丢掉围栏);同一套渲染代码 换个转换器再跑一遍,得到原样的 markdown。转换器由调用方(通道)自己挑:

  • QQ 默认发纯文本(msg_type: 0 + content,用 body 版);把通道的 markdown 打开才发原生 Markdown(msg_type: 2 + markdown.content,用 markdown 版), 标题加粗并与正文之间空一行(Markdown 里单个换行是软换行,会被并进同一段)。默认关的 原因见上面 QQ 一节:卡片宽度由客户端写死,电脑上比普通气泡窄一截;
  • 飞书 与其它通道用 body:飞书的 lark_md 只认内联子集,不渲染标题与表格, 不压平的话用户看到的就是 | 分类 | 记忆条目 | 这种源码。

两版同源同上限(都受 maxBodyChars 截断、都要分片),所以不会各自漂移。

routing —— 回复怎么找到会话

配置项默认值说明
allowPrefixtrue是否允许 519cc141 继续 这种显式前缀定向
fallbacklatest没有引用时发给谁:latest(最新一条通知的会话)或 intervention(最近一次需要人介入的会话)
tableTtlDays7路由表条目存活天数
echoTargettrue投递后是否回显「已发给 X」。强烈建议保持开启,否则投错会话你无从察觉

session / global —— 正文的形态

会话范围(推哪些会话)是每台机器人各自的,写在通道的 sessionScope / sessionId / sessionFilter 上(见上一节)。下面这两组只管正文怎么渲染。

当某台机器人的范围是「只关心一个会话」时,它收到的通知带完整上下文:

配置项默认值说明
context.includeAssistanttrue是否包含助手回复正文
context.includeToolstrue是否列出工具调用
context.includeTimingtrue是否带耗时
context.previousTurns3附带前 N 轮的摘要(每台机器人可用 historyTurns 覆盖)
context.includeUserPrompttrue是否回显提问
targetSessionId''已弃用:全局单会话开关的遗留字段,改由每台机器人的 sessionId 承担

范围是「全部 / 名单」的机器人保持精简:

配置项默认值说明
verbositybriefbrief 固定精简;normal 让所有会话都按详细正文渲染(不推荐,会刷屏)
includeSessionLabeltrue标题里带「项目 · 短id」
includeSummaryLinetrue是否附一行结果摘要
includeSubagentfalse同 events.includeSubagent

精简模式的正文保留行结构:元信息单独占一行,正文另起一段,段落、【小节】、 · 列表、缩进都原样留着,一个字符都不截——长度只由 content.maxBodyChars 控制 (默认 1800,可在设置页调,范围 200–20000)。早先版本为了「一行讲清楚」把正文折成 一行,助手回复一长就是一堵没有换行、没有分节的墙,所以改掉了。


🖥️ 设置页(GUI)

插件的客户端半边注册在宿主的设置里:打开侧边栏 →「设置」→ 左侧列表里的 「通知助手」。它是这个插件唯一需要的界面,覆盖全部配置项,改完即时生效 (走 rpc 通道热应用,只有极少情况才提示需要重启)。

区域能做什么
状态栏运行中 / 已停用、已推送条数、通道连通数(如 1/2)、已运行时长
状态总开关、默认通道
机器人左侧 QQ / 飞书 两个入口,每个机器人一张卡片;卡片能展开、能起名字、能测试连接、能移除;点「更多设置」进该机器人自己的子页
推哪些事件可以选择每个机器人各自推哪些事件(跟随全局 / 自定义)
正文内容同上:可以每个机器人各自决定正文细节
回复路由前缀开关、回显目标开关、兜底策略、路由表 TTL
高级日志级别、已升级为详细模式的会话名单,以及配置 / 状态 / 日志的实际落盘路径(方便排查)

0.1.4 之前这里还有一块全局的「运行模式」(全局 / 单会话)。它已经被删掉: 会话范围下沉到每台机器人自己的「会话过滤」里,顶部不再有一个能压住所有机器人的开关。

每个机器人独立设置

一个通道 = 一台机器人。每台机器人有三层可以各自决定的东西,互不影响:

层位置说明
凭据与接入卡片头部 + 接入向导AppID、密钥、目标 id、扫码接入、测试连接、启停
通知规则更多设置 → 通知规则推哪 6 类事件、正文放什么;默认跟随全局,可切成自定义
会话过滤更多设置 → 会话过滤三种范围:所有会话 / 只关心一个会话(可随时换)/ 只关心列表里勾选的几个;另外还能单独决定这条通知带不带该会话的历史

「跟随全局」是协议里的一条明确语义:events: null / content: null 表示回到全局那一份 (不是「关掉」),所以设置页在跟随状态下不会往配置里写一份会过期的副本—— 你后来改了全局开关,跟随中的机器人立刻跟着变。

「会话过滤」页签会列出最近用过的会话(真标题 · 项目 · 最近活动,标题取不到就退到项目名, 再取不到退到会话 id),点选即写入,不用手抄 id;读不到列表时页面上留着手填输入框兜底, 宿主也会把「为什么读不到」写在旁边。「只关心一个会话」档下没绑会话就等于谁都不推, 页面会明确写出这一点。

「附带历史记录」也是每台机器人一份:跟随全局 用全局的 session.context.previousTurns,自定义 写一个 0–20 的数字(0 = 这条通知不带历史)。 所以「全局不带历史、只有这台带 3 轮」是能表达的。

更多设置 → 高级里还放着按机器人区分的推送方式、绑定链接、飞书接收者类型与群 ID。

接入向导

每张卡片都有一个「接入向导」按钮,把接一台机器人拆成四步(可折叠、可跳过, 打开时自动停在第一个未完成的步骤):

  1. 去平台建应用(直接给官方入口链接;QQ 也可以在这一步扫码一键创建机器人)
  2. 填凭据(AppID + 密钥;QQ 扫码创建的话这一步会自动填好并保存)
  3. 拿目标 id(扫码绑定,或手填)
  4. 测试连接

扫码接入机器人

两种扫码是不同的两件事,别混:

QR 一:扫出机器人QR 二:扫出目标 id
用途在腾讯/飞书创建一台新机器人,并把凭据自动收回本插件让插件知道要把消息发到哪(聊天对象 id)
支持QQ:✅(q.qq.com 官方扫码建机器人流程);飞书:❌(见下)QQ / 飞书都支持(前提是通道已经能收消息)
入口卡片「扫码接入机器人」(QQ)/ 向导第 1 步的官方入口链接(飞书)卡片「扫码绑定」/ 向导第 3 步

QQ:点「扫码接入机器人」→ 手机 QQ 扫码 → 在官方页面上确认创建 → 插件收到 AppID / AppSecret 自动写进配置、自动启用该通道,然后继续走向导第 3 步。 这个流程走的是 @tencent-connect/qqbot-connector(腾讯官方包), 二维码由设置页自己渲染(宿主只把二维码里的 URL 交给浏览器,不画图)。

飞书:扫码创建应用需要 AppID 之外的额外授权,本插件不做 (也不引入体积 30 MB 的飞书 SDK 只为了注册应用)。飞书这条路是: 自己到 open.feishu.cn 建自建应用 → 把 AppID 填进通道 → 插件按官方 applink 规则推出 https://applink.feishu.cn/client/bot/open?appId=<AppID> 二维码 → 手机扫码打开机器人对话 → 随便发一条消息 → 插件记下发信人 id 并填进目标 id。 也就是说飞书是「扫码打开机器人,手动建应用」,第 3 步的扫码与下面「扫码绑定」是同一件事。

扫码绑定(QR 二,两种通道通用):点卡片(或向导第 3 步)的扫码绑定, 页面出现二维码(飞书按 AppID 推导出机器人 applink;QQ 需要你在开放平台复制机器人的 分享链接、填进该通道的绑定链接)→ 手机扫码 → 打开与机器人的对话 → 随便发一条消息 (内容不限)→ 页面每 2 秒轮询一次,收到消息后自动把发信人 id 填进目标 id 并保存。 二维码有 10 分钟有效期;若推不出可用链接(比如 QQ 通道还没填绑定链接),设置页会 明确说明缺什么,而不是画一个扫不出来的二维码。也可以跳过扫码:直接用手机给 机器人发一条消息、把 targetChatId 手填进去。

密钥的处理方式

密钥永远不会被回显。宿主回给浏览器的配置里,appSecret / feishuAppSecret 只有两个字段:configured(是否已设置)和 hint(形如 ••••a1b2 的末四位)。 因此设置页在「你没动过密钥」时根本不会把密钥字段放进保存请求——协议里 「字段缺席 = 保持原值」,只有你点「改」并确认时才会发出那一个字段。这保证了 「看一眼设置页就把密钥抹掉」不可能发生。

测试连接

每个通道都有测试连接按钮:它用与真实通知完全相同的发送路径发一条消息。 收到就说明凭据与目标 id 都对。失败时页面显示的是可执行的建议而不是平台原始 报错(例如「请在 QQ 客户端打开该机器人的『允许主动发送』——这个开关在用户侧」)。


💬 IM 命令

在通道里直接给机器人发这些话。/mode 系列只改「发命令的这台机器人」—— 同一台 DSH 里挂了三台机器人,你在 A 的窗口里发 /mode global,B、C 完全不受影响。

命令作用
/mode查看这台机器人当前关心哪些会话(全部 / 一个 / 名单里的 N 个)与详细模式名单
/mode global让这台机器人关心全部会话
/mode session <短id>让这台机器人只关心这个会话,之后只推这一个
/help帮助
detail(也可写 detail on)把当前路由到的那个会话升级为详细模式(精简范围内临时看细节)
undetail(也可写 detail off / brief / 精简)恢复精简。通知里给的提示就是「回复 detail off 恢复精简」,整句复制回来也生效
stop中止当前路由到的会话
<短id> 你的话显式定向投递到那个会话
直接说话按三层路由投递(推荐长按引用某条通知再回复)

等待中的提问 / 审批:可以直接回文字

按钮不是唯一入口,更可靠的是回文字(QQ 单聊的按钮在桌面端和旧版手机上不渲染, 宿主那边只要没人结算就会一直等):

情况你可以回
提问1(序号)、选项原文,或任意一句自由文本(自由文本直接当答案)
提问:自定义回答先回最后一行那个序号(候选数 + 1),再发一段文本——那段文本原样当答案
多选提问1 3 / 1、3 / 1,3——一次提交;或者点选项勾选(见下)
审批允许 / 同意 / 批准 / yes / 1;拒绝 / 不同意 / deny / 2
计划确认批准 / 不批准(不认自由文本——免得随手一句话就把计划批了)

提问正文里的选项是编号分行的,最后一行固定是「N+1. 自定义回答(先输入序号再输入 文本)」。直接发一段自由文本本来就等于自定义回答;回那个序号只是多给一条明确的路 (回完你会收到「好,把你要自定义的答案发过来」,下一句话不再当序号解析,哪怕它长得像 2)。多选和计划确认没有这一行:多选按空格拆 token,塞不进这套两段式。

多选提问的按钮只负责「勾选」,选完点通知里的**「提交所选」才把候选一起交给模型。 所以多选的通知比单选多一个按钮、选项最多显示 5 个(MAX_ACTION_BUTTONS 是 6)。 回文字则相反——它是完整的一次提交**,以你发的那句为准。

复选框长在按钮上(正文里一个都没有):

可选:
1. 甲
2. 乙
(可多选)
可多选:点下面带 ☐ 的按钮勾选(点过就变 ☑,按钮取消不了勾选),选完点「提交所选」…

[☐ 甲] [☐ 乙] [提交所选]

点一下按钮,QQ 客户端就把那颗按钮的文字换成 ☑ 甲(render_data.visited_label)。 勾选时不再回消息——按钮自己变 ☑ 就是反馈,每次点击都回一条会把对话刷成一串 「当前勾选」(实测用户明确反对)。不能显示按钮按下态的通道(飞书)才需要那条回执。

按钮只能勾、不能取消。 这不是偷懒,是平台限制的必然结果:QQ 的 visited_label 只在 点下去那一刻生效,之后没有编辑消息的接口,所以「取消」这个动作会在卡片上留下一个撤不 掉的 ☑ ——显示与真实状态永久不一致。既然按钮只能忠实地表达「选上了」,就让它只负责选; 要减掉某项,直接回最终序号(例如回「1」或「1 3」),那是一次完整提交,会覆盖点过的集合。 重复点同一个按钮不会取消,只会收到一条提示告诉你怎么减项。

为什么不能做得更像复选框:

  • 正文里的字符永远点不动。能回调到机器人的只有键盘按钮(action.type = 1 回调按钮)。 早期版本在正文里写过 ☐ 1. 甲,用户当场去点那个 ☐ ——点不动,于是判定「功能没做」。
  • QQ 的 Markdown 没有任务列表语法:官方「支持格式」 只列了标题 / 文字样式 / 链接 / 图片 / 有序列表 / 无序列表 / 嵌套 / 引用 / 分割线 / 换行, 没有 - [ ]。就算客户端把方框画出来,它也只是本地字符,回调不到后台。
  • 键盘也没有复选框控件:action.type 只有 0 跳转 / 1 回调 / 2 指令 (消息按钮、 发送单聊消息), render_data 只有 label / visited_label / style;已发出的消息也没有编辑接口 (互动事件回应 PUT /interactions/{id} 只能带一个 code),所以 QqChannel.update() 恒返回 undefined,勾选态无法原地刷新。

这一条也是修出来的(三轮):① 原来多选和单选走同一条路,第一次点击就把整题答了,点第二个 只会收到「这个请求已经结束或过期了」;② 改成「再点一次=取消」之后,又出现「每次点击都发 一条消息」+「取消后原始卡片上那颗按钮还是 ☑」;③ 最终定成「按钮只选不取消 + 勾选静默」。

通知上的两个操作按钮:会话短id 与 详细模式

每条通知底部(按钮位够时)还会带这两个,它们不结算任何等待,只作用于那条通知所属的会话:

按钮它到底做什么什么时候出现
详细模式把这个会话之后的通知从「精简」升级为「完整上下文」:带上历史轮次、工具列表、你上一轮的提问与耗时。等于替你回了一句 detail只在精简模式的通知上;已升级的会话不再显示(想恢复就回 detail off / undetail / 精简)
会话短id回你这条通知所属会话的短 id,以及「回复 <短id> 你的话 就能定向投递」的用法按钮位够时都有

会话短id 以前叫「打开会话」——那个名字是错的:IM 里没有通往 DSH 界面的跳转能力, 点了并不会打开任何东西。它给的信息(短 id)本来也印在标题里 (DSH · <项目> · <短id> · <事件>),留着只是给「想定向回复但懒得翻标题」的人一个按钮。

结算按会话找最新一条还在等待的请求,所以同一会话里连着两条等待时,先答最新的 那条。文本没对上任何等待中的请求时,它会照常作为普通发言注入会话。通知正文里也带了 提示(「回复序号或选项文字即可作答。」)。自由文本与自定义回答都以宿主的 custom 字段 回传(和 Web 界面的输入框一致),不会混进 selected。

抢答也不会丢:通知发出去和宿主把提问挂到等待列表之间有个窗口(现场实测约 50 秒, 宿主先把问题交给 Web 界面、之后才轮到插件)。这个窗口里回的话会先被攥住——你会立刻 收到「已收到「1」,等提问就绪就替你答。」,等提问一就绪就自动替你作答(并回显 「已选择「…」」),不会再被当成补充语句注进会话,那条迟到的通知也不会再发一遍。 如果提问始终没就绪,60 秒后这句话会按普通发言投回会话,不会石沉大海。

拿不准的话不会被当成审批结果:宁可少答一次,也不把你要说的话吞掉。判读规则与 「等待列表已经在」时完全一致(同一个 matchAnswer),所以攥住不会比正常作答更激进。


🧭 回复路由(三层,顺序不可颠倒)

  1. 按钮。按钮的 value 自带 sessionId,零歧义。审批 / 提问 / 计划按钮同时完成 「结算」与「路由」两件事。
  2. 长按引用回复。用 byMessage / byRefIdx / byThread / byContent 四张表反查 被引用消息属于哪个会话。QQ 靠 ref_msg_idx,飞书靠 parent_id ?? root_id。
  3. 显式前缀。519cc141 继续 —— 短 id 是会话 id 去掉 session- 前缀后的前 8 位。

兜底:都没有时,默认发给最新一条通知的会话(fallback: "intervention" 则发给 「最近一次需要人介入」的会话);该会话已经不存在就再退一层;都没有就回 「没有可投递的会话」。任何一次投递都会回显「已发给 X」,包括兜底路径 (会额外标注「没有引用,按最新通知投递」)。

路由表有 TTL(默认 7 天),并且落盘到 state.json——重启后引用一条三天前的通知仍然有效。


🗂️ 文件与状态

全部落在 $DSH_HOME/tlnotify/:

文件内容
config.json有效配置。补丁里没写的字段会从这里读;/mode 也写这里
state.json路由表(byMessage / byThread / byRefIdx / byContent / latest / lastIntervention)、详细模式名单、入站去重表
plugin.log插件日志,1 MiB 轮转保留一份 .1

写入是原子的(先写 .tmp 再 rename),落盘是防抖的(1.5 秒), 且所有文件操作失败都不会抛异常——通知插件绝不能因为磁盘问题把宿主拖下水。


🧱 源码结构

文件职责
src/index.ts插件入口:Config、apply、把下面所有模块接线起来
src/types.ts全部领域类型;不 import 任何 @deepseek-ai/* 运行时模块
src/config.ts配置解析 / 合并 / 落盘,以及 schemastery Config
src/protocol.ts宿主与浏览器共用的线上契约:RPC 方法名、请求 / 响应类型、常量。两边都只从这里取类型
src/rpc.ts宿主侧的纯函数:patch 校验与合并、密钥脱敏、平台报错翻译、绑定链接推导。不做任何 IO
src/events.ts会话事件归一化:9 类事件、项目名、短 id、子 Agent 判定
src/aggregate.tsTurnAccumulator:累积助手正文与工具调用,产出 TurnSnapshot
src/gate.tsCompletionGate:等 agent 真正空闲再发,防抖 + 重试上限
src/dedupe.ts按 会话:seq 去重(24 小时窗口 / 2000 条上限)
src/render.ts标题三段式、正文渲染(同时产出纯文本 body 与原样 markdown 两版)、按钮生成、长文本分片
src/markdown.tstoPlainText():把模型写的 Markdown 压成 IM 看得懂的纯文本。不幂等,整段只压一次
src/route.tsRouteTable:三层路由 + TTL + 序列化(正文登记与引用反查前都做同一套规范化,见 normalizeForMatch)
src/mode.tsModeState:详细模式名单、遗留全局模式字段,以及 /mode 命令解析与每台机器人的范围文案
src/inject.tsSessionInjector(回注消息、中止会话)与 InteractionBridge(审批 / 提问 waterfall)
src/log.ts带轮转的文件日志,失败静默
src/provision.tsProvisionManager:扫码创建机器人的会话(begin / poll / cancel),拿到凭据后回写配置
src/channels/index.tsChannelManager:启动 / 停止 / 发送 / 故障转移 / 分片
src/channels/qq.tsQQ 单聊机器人通道(纯文本 / 原生 Markdown 双载荷 + 内联键盘, / / )

客户端半边(设置页)

文件职责
src/client/entry.ts构建入口,转出 apply / inject
src/client/index.tsxapply:注册 settings.section 插槽、注入样式;语言变化时重注册 label
src/client/host.ts宿主客户端 API 的结构化类型声明(不 import 宿主包)
src/client/rpc.tsrpc.call 的薄封装,把失败统一成 RpcResult
src/client/i18n.ts中英词表与按语言取文案;无框架依赖
src/client/draft.ts通道草稿模型:宿主视图 ⇄ 可编辑草稿 ⇄ ChannelPatch
src/client/qr.ts二维码点阵生成(qrcode 的模块矩阵 → 0/1 位图)
src/client/styles.ts客户端样式(按 id 幂等注入 / 移除)
src/client/components/ui.tsx裸件层:按钮、输入框、开关、分段控件、提示条
src/client/components/SecretField.tsx密钥三态输入(未设置 / 已设置 / 编辑中)
src/client/components/QrCode.tsx点阵渲染成 SVG <path>
src/client/panels/ChannelPanel.tsx通道区(含测试连接、扫码绑定)
src/client/panels/SettingsPage.tsx设置页本体;所有状态与「一次改动发哪条 patch」集中在这里

构建产物 web/client.js 是一个自包含 IIFE,外面包一层宿主约定的 window.__ModuleLoader__.load({ id, factory }),id 必须等于包名。 react 是唯一的 external——它由宿主 Web 应用提供,插件不能自带(否则 hooks 报 invalid hook call)。

九个值得记录的宿主事实

  1. apply 的返回值会被丢弃。Cordis 3.0.0 的 MainScope.apply 是 this.ensure(async () => plugin.apply(...)),返回值直接丢掉。所以真正的卸载路径 是绑在 ctx 上的副作用(ctx.on('dispose', …)),同时仍然返回幂等 dispose 供测试和会消费返回值的宿主使用。
  2. 审批没有「按 id 答复」的 API。唯一的公开途径是 'approval/request' waterfall:返回 'allowed-once' 放行、'rejected' 拒绝, 或调 next() 委托给别人。提问同理走 'user-questions/request' waterfall。 InteractionBridge 把这两个 waterfall 桥接成「发一条 IM 消息,等按钮回来」。
  3. 不要在 agent 级监听器里 await agent.whenIdle()——宿主的类型注释明确警告会死锁。 完成门控只在 session/event(非 agent 事件)里查 agent.status。
  4. 设置页的 RPC 只能挂在 connection.fetch.register 上。官方生态走的是 ctx.connection.fetch.register({ path: '/api/tlnotify/<method>', methods: ['POST'], … }), 而不是 connection.rpc.handle:后者内部要 owner.webServer,插件 ctx 没声明它就抛 (声明了又会因为 webServer 是激活门而让 headless 部署永不激活插件)。返回给浏览器的是 connection.rpc.call('/api', 'tlnotify/<method>', payload)。/api 前缀路由由 dsh-client-connection 自己挂,Host/Origin 校验与浏览器鉴权在插件 handler 之前。
  5. { patch } 信封是宿主侧的解包责任。契约(src/protocol.ts)写的是 patch: { request: { patch: TlnotifyPatch } },设置页发的就是 { patch: next }; 宿主必须先取 payload.patch 再交给 applyPatch。两者各自有测试,但合起来也要有一条 端到端用例(tests/roundtrip.spec.ts)——这个洞曾经让设置页的每一次写入都被白名单拒掉, 而页面看起来完全正常(读接口不走这条校验)。
  6. 「谁关心谁收到」必须逐台发,不要借用故障转移。投递一度走「按顺序试通道、第一台成功就 return」的故障转移语义,再叠一道全局单会话闸门(#mode.shouldPush)。于是「把 A 设成 单会话,勾了关心全部的 B 也一起哑」,以及「B 抢走了本该属于 A 的会话」。现在闸门只判插件是否 可用(#canPush(),不看会话),投递用 sendEach() 逐台发、不短路,会话范围由每台机器人自己的 channelSessionScope() 决定。多租户订阅不要复用「高可用」的控制流。
  7. 同一条等待会从两条路进来,去重键却不同。session/event 的 tool/call 路径用真实 seq 去重, InteractionBridge 的 waterfall 路径用 requestId 合成的负数 seq 去重 ⇒ 同一条「等待我回答」 被投两遍(现场日志里成对出现、相隔约 100 ms)。现在两条路都按 requestId 再过一道 #deliveredRequests,谁先到谁发。同一件事有多条来源时,去重键必须统一到「这件事的身份」上。
  8. 。用户看不到通知时,日志是唯一的证据:被 滤掉、被判成重复、 没有任何机器人接——这些以前都是 debug 或完全无声,于是「有时候不提醒」无从定位。现在等待类 (提问 / 审批 / 计划)一律留一行 info,并逐台列出「为什么没发给它」。 :QQ 单聊的键盘按钮在桌面端 / 旧版手机上不渲染(宿主侧完全静默), 所以 的文本作答才是兜底路径。

🛠️ 开发

本仓库统一使用 pnpm(见根目录 AGENTS.md):

# 类型检查(宿主 + 客户端两套 tsconfig,零错误)
pnpm --filter dsh-plugin-tlnotify run typecheck

# 构建产物:dist/index.js (ESM) + dist/index.cjs (CJS) + dist/index.d.ts + web/client.js
pnpm --filter dsh-plugin-tlnotify run build

# 只重建设置页产物
pnpm --filter dsh-plugin-tlnotify run build:client

# 单元测试(零网络:全部使用假宿主对象)
pnpm --filter dsh-plugin-tlnotify run test

# 设置页冒烟测试:在 jsdom 里把 web/client.js 当宿主加载一次,
# 真渲染一遍并点几下按钮,断言发出的 RPC 调用是对的
pnpm --filter dsh-plugin-tlnotify run smoke:client

# 全工作区递归构建
pnpm run build

📄 License

MIT License。本项目是 DeepSeek Harness 的第三方插件,与 DeepSeek 官方无隶属关系。

第三方依赖与参考项目的声明见 THIRD_PARTY_NOTICES.md。

buildSendPayload()
composeQqText()
applyMarkdownPolicy()
src/channels/feishu.ts飞书自建应用通道
等待类事件不许静默跳过
#wants
交互入口也不能只有一种
InteractionBridge.settleText()
  • 「先发通知」和「能收作答」不是同一时刻。tool/call 那条路 19:21:06 就发出了「等待我回答」, 可 InteractionBridge.#pending 直到 19:21:56(waterfall)才注册上——用户在这 50 秒里回的「1」 被当成普通发言注进会话(他看到的「像补充语句」)。通知的可操作性要在发送前就成立:现在 #waiting 会把窗口内「像作答」的话攥住(判读用与 settleText 同一个 matchAnswer), pending 一就绪就替他答、并吞掉那条迟到的通知;等不到就 60 秒后按普通发言退回。 别假设「通知发出去」等于「对方能回答」。