DeepSeek Harness Hub
← 返回列表

masquerator-coder/dsh-im-gateway

DeepSeek Harnessspec-screened在 GitHub 查看 ↗
✓ 可直接安装

dsh-im-gateway — DeepSeek Harness IM 网关插件

自动检查通过:npm 包已发布且 engines 声明满足基线(声明 Node ^22.19.0 || >=24.0.0);该结论来自程序自动检查,未经人工实机验证。 · 最近上游提交 2026/9/18 · 已提供中文文档

Deepseek 接入网关

综合分
30.8
GitHub 分
30.8
用户评分
★ Stars
1
周下载量
安装插件(需先安装 dsh CLI 引擎:npm install -g @deepseek-ai/dsh)
dsh plugin --profile web add dsh-im-gateway
npm 包 dsh-im-gateway 已校验归属本仓库,走 npm 安装最省事
数据截至 2026/9/18(元数据每日更新 · 实装验证按队列轮转,单条结论的验证时间见上方)
安装兼容性检查✓ 自动检查通过

以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。

npm 包dsh-im-gateway @ 0.3.1
Node 引擎要求 ^22.19.0 || >=24.0.0 · 基线 Node 22.19 满足
dsh CLI 依赖未声明 dsh 版本约束
入口文件main/exports/bin 已声明

验证方式:npm registry 存在性 + package.json 静态校验 · 最后验证 2026/9/19 09:09:49

依赖的 DSH / Cordis 模块
@deepseek-ai/cordis@deepseek-ai/dsh-agent@deepseek-ai/dsh-agent-default-model@deepseek-ai/dsh-llm@deepseek-ai/dsh-session@deepseek-ai/dsh-settings@deepseek-ai/schemastery@deepseek-ai/dsh-client-ui-slots@deepseek-ai/dsh-client-locale@deepseek-ai/dsh-client-ui-settings@deepseek-ai/dsh-api-remotes
用户评分
还没有人投票,来当第一个
订阅周报,不错过优质插件更新
每周一封 · 高评分插件 + 新用户活动

README

dsh-im-gateway — DeepSeek Harness IM 网关插件

一个 DeepSeek Harness(Cordis)插件,用于将外部 IM 平台桥接到 Harness:

1. 入站 — 多通道网关接收来自外部 IM 平台的消息。
2. 桥接 — 每条消息被注入到一个持久化的 Harness Agent,该 Agent 与外部聊天稳定映射,因此对话在跨消息时保持上下文,而不同聊天(以及不同通道)保持隔离。
3. 出站 — Agent 的回复通过 rpcId 认领从全局会话事件流中收集,并通过接收它的同一通道投递回去。

它同时提供旧版单一 HTTP webhook 和多通道设置 UI(DSH 设置面板中的“IM 通道”),覆盖六种通道类型 — 微信(ilink bot)、QQ(官方 bot)、邮箱 Email(SMTP/IMAP)、中国移动 5G消息(WebSocket)、飞书(官方 bot),以及通用 HTTP 回调。

工作原理

external IM --POST--> [channel transport (webhook / WS / IMAP / QQ bot / ilink)] -> [workspace-attached Agent/session per chat]
^                                                                                              |
|                                                                                global session/event stream (rpcId claim)
+-- ,或者当显式配置的工作目录适用时(通道自身的 cwd,或设置卡片中的全局默认工作目录),为 im-。
- 接收通道是键的一部分,因此同一个外部聊天 id 通过两个不同通道(例如 email 与 cmcc)到达时,永远不会共享会话(隔离方式与 dsh-im-main 的 ConversationRoute 一致)。空命名空间会为早于多通道的调用方保留历史上的仅 chatId 键。
- 工作目录是键的一部分,因为 DSH 会话的 cwd 在会话创建时就被固定:agents.resume 会恢复持久化的头部(ResumeAgentOptions 没有 cwd),而 workspaceRegistry 拒绝附加头部 cwd 与工作区路径不同的会话。因此,一个对话在其整个生命周期内都存在于一个工作区中,将通道指向另一个目录会在那里开启一个新对话——旧会话仍留在其旧工作区中(仍可在 Web UI 中打开)。静默恢复旧会话,正是过去让更改后的工作目录看起来被忽略的原因。当未配置目录时,键保持不变(并且每个现有 IM 会话都保留其 id):cordis.yml 的 cwd 仍然是纯粹的部署回退,不会限定身份范围。
- 相同的 channel + chat_id(+ 相同的工作目录)始终复用同一个 Agent(持久上下文);不同的聊天永远不会共享。

内置网关防护措施

- 发送者访问控制——当配置了 allowlist 时,只有那些 senderId 可以驱动 agent;未授权(或无发送者)的消息会在任何 agent/工作区/模型副作用发生之前被拒绝。空 allowlist = 允许所有(依赖 secret / 私有网络)。
- 入站去重——在 5 秒内重放/回显的相同 chat + text 会被抑制,因此平台重放永远不会重复触发模型回合。
- 按会话串行化——每个聊天最多只有一个进行中的回合:并发消息会在按会话的尾部排队,而不是覆盖彼此的回复声明。
- 来源元数据注入(变更时)——仅当该会话的来源发生变化时(其第一条消息,或不同的发送者/通道),才会在提示前添加一个 {channel, senderId} 块,这样模型仍能得知是谁/哪个通道发问,而该块不会在每条气泡上重复——第一个块已经保留在重放历史中。当压缩遮蔽了承载它的跨度时,它会重新发出。
- 有界投递重试——回复通过 sink 推送,最多尝试 2 次;每次失败都会记录日志,最终放弃时会明确记录 reply NOT delivered(不会静默丢失)。
- 实时会话复用(绝不与另一个所有者争抢)——在宿主中,一个 Agent/Session 是单写入者的。当同一个会话已经在别处处于实时状态时(典型情况:操作员在 Web UI 中打开了那个 IM 会话),resume 无法取得写入所有权,create 也无法重新进入该 id——两者都会失败。因此,当宿主已经有一个实时 agent 时,网关会复用它(ctx.agents.get,与 DSH 的 createOrAdopt 完全一样),并且绝不会销毁不是它创建的 agent。如果没有这一点,在浏览器中打开该会话会静默地使聊天静音:传输层持续轮询,报告自己已连接,而每一条入站消息都被丢弃,只留下一条宿主日志警告。
- 失败绝不静默——一个无法回复的轮次(agent 获取错误、获取超时、模型错误、空回复、投递失败)会被报告回同一个聊天(⚠️ 处理失败,未能回复。原因:…),并反映在该通道的状态行中,因此一个“已连接 but 永远不回复”的通道会变成一个可见、可操作的错误。Agent 获取是有界的(60s),因为那里无界的等待过去会永远卡住该聊天的序列化尾部,并把它后面的每一条消息都丢弃。
- 诚实的连接状态——微信传输层唯一的存活信号是 getupdates 往返:连续 10 次失败(约 15 秒)会把该通道从 connected 降级为 error 并附上原因,而下一次成功会恢复 connected。被撤销的会话(errcode -14)会在任何往返中被报告,而不仅仅是第一次。CMCC socket 有一个独立的看门狗(socket 状态 + ping/pong 新鲜度),因此在睡眠/网络变化后处于半开状态的 socket 会被强制重连,而不是静默地保持“connected”。QQ 网关也遵循同样的规则:只有当网关以 READY/RESUMED 回应 IDENTIFY/RESUME 后,start() 才会成功;不可重试的关闭码(4013/4014/4914/4915)会以中文呈现并附上修复方法,并且停止重连循环,而不是永远循环;停止响应心跳的 socket 会被强制重连(参见 QQ 通道)。

IM 侧确认(审批 / 用户问题)

DSH 的工具审批(approval/request)和用户问题(user-questions/request)是 agent 作用域的瀑布事件。在这个网关创建/恢复的每个 agent 上,都会安装一个桥接应答器,它把提示推送到驱动该会话的同一个 IM 通道,并把用户的文本回复映射回该接缝所期望的结果——因此 5G消息 / 电子邮件 / … 用户是通过 IM 被询问的,而不是只看到一个 Web 对话框。
顺序是承重的:在 web profile 中,为浏览器应答器提供输入的 dsh-api-remotes 转发器会在启动时注册到根上下文,而 Cordis 的 waterfall 按注册顺序运行监听器(作用域过滤只决定是否接纳监听器,不会重排它们)。因此,bridge 以 prepend: true 注册,从而位于 waterfall 的最前面,让 IM 通道胜出。回退机制:如果会话没有可达的出站发送器(或 IM 推送失败),bridge 会调用 next() 并委托给 web 应答器,而不是失败关闭——离线的 IM 通道永远不会卡住一个 web 用户本可以应答的审批。纯 web 会话不受影响(只有网关拥有的 agent 才会安装 bridge)。

多通道 IM 管理(设置 UI)

在 DSH 的「插件 → 插件设置」页面中,一张“IM 通道设置”卡片(样式与其他系统插件卡片一致)点击后展开,显示各通道的管理 UI。每种通道类型都附带一个傻瓜式预填模板,因此固定项已经正确,用户只需填入挑选好的 key/token/账号(或扫码):

| 类型 | 自动填好的固定项 | 用户提供 | 传输方式 |
| --- | --- | --- | --- |
| 微信(wechat) | baseUrl(https://ilinkai.weixin.qq.com) | 什么也不用填:扫码绑定(bot_token 由网关下发并落盘) | 腾讯官方 ilink bot 网关的直接客户端(扫码绑定 → getupdates 轮询 → sendmessage) |
| QQ | botApiBase(https://api.bot.qq.com) | AppID + AppSecret(在 q.qq.com 创建 bot) | 官方 QQ bot WebSocket 网关(getAppAccessToken → /gateway → wss;C2C/群聊 + 公域频道@) |
| Email | 来自所选服务商的 server/端口/TLS(QQ/163/Gmail/Outlook/企业微信/自定义) | 账号 + 授权码/密码 | nodemailer(SMTP 出)+ imapflow(IMAP 入;首次只处理最近 50 封) |
| 中国移动 5G消息 | serverUrl(wss://…/ws/msg)、version: 2.0 | apiKey | 到 5G 消息网关的 WebSocket SmsClient |
| 飞书 | — | App ID + App Secret | 官方 Lark/Feishu SDK WebSocket 长连接(已 vendored 到 lib/vendor/ —— 见 安装) |
| 通用 HTTP | inboundPath /im、字段映射(chat_id/text/sender_id) | callbackUrl +(可选)secret | 共享的入站 node:http webhook 路由 |

每个启用的通道都持有一个实时连接(connected / connecting / error / idle),宿主通过一个插件自有的 web 路由把状态回报给 UI —— GET /im-gateway/status(用 ctx.webServer.register 注册,受与 /api 相同的浏览器鉴权检查保护,cache-control: no-store),面板每 3 秒轮询一次,文档隐藏时暂停。面板还会渲染微信扫码登录的二维码,并在存在时显示连接详情。对于有绑定状态的类型,payload 会带一个 bound 标志:微信一旦绑定,面板就显示「已绑定微信,无需再扫码」,而不是通用的“此处会出现二维码”提示,这样已完成的配对就不会看起来像面板坏了。
为什么不是 ctx.remote:DSH 的 ctx.remote. 是 Typert 生成 的描述符投影 —— 浏览器侧只挂载 DSH 自带 assembly 里那份固定清单(@deepseek-ai/dsh-api-remotes/client),且拒绝任何没有 strict 生成 codec 的描述符(requireStrictDescriptor)。树外插件无法发布 Remote namespace,所以本插件改为注册一条同源 web 路由(也顺带复用守卫 /api 的浏览器鉴权 cookie)。

Channel 记录存放在 im-channels 设置命名空间下,其中机密字段(apiKey、password、appSecret、token、……)声明为 role('secret') —— 在每个传输边界上都会被脱敏,只有宿主传输层才能从设置作用域中读回它们。

每张通道卡片都提供一个 高级选项(接入控制 / 模型路由) 折叠区,用于配置与旧版 webhook 共享的 agent 路由字段:allowlist(每行一个发送者 id —— 邮箱地址 / QQ / 手机号 / HTTP sender_id)、provider、model、maxTokens、cwd、agentPreset,以及整个通道的启用/停用开关。这些配置按通道实例生效:同类型的两个通道(例如两个 http webhook)即使外部 chat_id 冲突,也绝不会共享同一个 agent 会话;而没有自己 allowlist 的通道允许所有发送者 —— 它绝不会继承旧版全局 webhook 允许列表(其 sender-id 语义属于那个 HTTP 调用方)。

在双列之上,卡片带有一个区块级字段,全局默认工作目录(im-channels.cwd):即每个没有自己 cwd 的通道所使用的工作目录(一个真实的 Harness 工作区,存放会话日志并赋予 agent 文件作用域)—— 通道自己的 cwd 始终优先。每条入站消息的解析顺序为:通道 cwd → 设置页全局默认 → cordis.yml 的 cwd → ~/.dsh/im-workspace。它在消息到达时读取,因此保存后即对每个通道的下一条消息生效,无需重连任何东西;只有更改通道自身记录才会重启该通道(无关的保存不再弹跳每个已启用的连接)。

由于 DSH 会话的 cwd 在创建时即被固定(且工作区注册表拒绝附加 header cwd 不同的会话),更改工作目录会在该聊天的下一条消息时在新目录中开启新对话:旧会话留在其旧工作区中,仍可在 Web UI 中打开,而聊天则在您配置的目录中以全新上下文继续(参见 Session keying)。从未配置目录的聊天会保留其会话 id,因此升级本身不会重置任何内容。

微信通道(直连官方 ilink 网关)

微信通道是腾讯官方 ilink 机器人网关的直接客户端(无需本地伴随进程),移植自 dsh-clawbot 参考实现。默认网关:https://ilinkai.weixin.qq.com(字段 baseUrl;除非您自托管网关,否则保持默认)。生命周期:

1. 保存并启用通道 → 面板在「接入步骤」正下方显示官方登录二维码(baseUrl 已预填,面板不提供 Token 输入框)。
2. 手机微信扫码确认绑定 → ilink 下发 bot_token,自动持久化到 ~/.dsh/im-workspace/wechat-state/.json(跨重启复用,无需重复扫码)。
3. 在微信里给新出现的 bot 联系人发一条消息解锁发送凭证 context_token。
4. 状态变为「已连接」后,绑定账号在微信里发的文本/语音转写会驱动 Agent,回复经同一 ilink 网关回送。

关于二维码怎么画出来的:get_bot_qrcode 返回的 qrcode_img_content 不是图片,而是一个 HTML 页面 URL(https://liteapp.weixin.qq.com/q/...,content-type: text/html);那个页面自己用 toCanvas(canvas, window.location.href) 现画二维码,所以可扫的字符串就是该 URL 本身。面板因此本地用 qrcode-generator 把同一个 URL 编码成 SVG 二维码(src/client/qr.ts,自绘白底、4 模块静默区,暗色主题也能扫);旁边保留「打开登录二维码」链接作为兜底。之前把它塞进  只能渲染出一个破损图。

获取二维码失败会自动重试:requestQr 无论抛异常还是返回不可用内容,都会把「获取二维码失败,正在重试…」写到状态行,并在未绑定期间每 10 秒重试一次——不会再出现「面板静静停在提示文案上」。

「已连接」是可证伪的:绑定通道的存活信号就是 getupdates 往返。连续 10 次失败(约 15 秒)会把状态从「已连接」降级为错误并写明原因(与微信网关通信失败(连续 N 次),正在重试),下一次成功再自动回到「已连接」;会话被吊销(errcode -14)在任何一次往返都会立刻报错,而不是只在首次连接时检查一次。所以「面板说已连接,但发消息不回复」现在只有两种可能:入站没到(状态栏会说话),或者失败原因会直接回发到微信(见 Gateway safeguards)。

一个会话同时只能有一个写入者:如果你在 Web 界面里打开着这个 IM 会话,宿主里它已经是 live 会话。插件会复用那个 live agent(与 DSH 自己的 createOrAdopt 同一规则),而不是去 resume/抢写锁;抢锁失败在旧版本里是静默的,现象正是「微信发消息不回复」。另外插件永不 dispose 不是自己创建的 agent。

面板为什么没有 Token 输入框:bot_token 只是 ilink 网关凭证,单独拥有它并不会连接微信——绑定是「扫码 + 解锁发消息」两步完成的,token 在扫码确认后由网关下发、由宿主写入 wechat-state/。所以它不该由用户填写(早先的版本逼着用户粘贴,反而把常见的误解坐实了)。通道记录里的 token 字段仍然保留:手工编辑 settings.yaml 预置凭证这条路径还在,宿主会优先使用 wechat-state/ 里的绑定结果。若通道只有 token 而没有绑定微信账号(无 scannedUser),网关会仍然显示登录二维码并提示「已填写 token 但尚未绑定微信」,而不是误报「已连接」。

边界(与参考实现一致):ilink 网关对主动发送严重限流——这是通知/拍板渠道,不是聊天工具;context_token 只会在绑定账号先发一条消息后下发;收到 转发 的文章/文件收不到(需发原始链接)。绑定状态默认只发给绑定账号自己。

QQ 通道(官方 QQ 开放平台机器人)

QQ 通道是官方 QQ 开放平台机器人网关的直接客户端——在 q.qq.com 创建机器人,复制其 AppID + AppSecret,此传输层负责官方 WebSocket 网关:POST https://api.bot.qq.com/app/getAppAccessToken → GET {botApiBase}/gateway → 连接返回的 wss://…(Hello → IDENTIFY/RESUME → heartbeat)以接收 C2C_MESSAGE_CREATE / GROUP_AT_MESSAGE_CREATE / AT_MESSAGE_CREATE,并将回复发送到 {botApiBase}/v2/users|groups/{openid}/messages。

从零到能用(最短路径)

1. 在 q.qq.com 创建机器人,记下 AppID / AppSecret(开发设置里)。
2. 申请所需能力:单聊 / 群聊(这是「在 QQ 里跟机器人对话」的前提),提交审核。未通过前连接会被网关拒绝——插件会把拒绝原因原样显示在面板上(见下)。
3. 面板「IM 通道设置 → QQ」新建通道,填 AppID + AppSecret,保存启用。状态变「已连接」即代表 WebSocket 已 READY。
4. 在 QQ 里给机器人发消息(单聊直接发;群聊需 @ 机器人),Agent 的回复经同一网关回送。
5. 先验证链路而不经过宿主:pnpm qq-probe  (见 QQ 真机探针)。

- 默认 API 基址 botApiBase: https://api.bot.qq.com(官方 2026-09 口径的「统一请求地址」;旧域名 https://api.sgroup.qq.com 仍然可用,老记录不必改)。sandbox: true(手工编辑 settings.yaml)切到 https://sandbox.api.sgroup.qq.com。
- AppID 字段非机密(它是机器人 ID,面板会显示已保存值);AppSecret 是 role('secret') 字段(复用飞书的 appSecret),只保存在本机设置里、任何线上边界都会被抹掉。
- 订阅事件(intents):默认 c2c + public_guild(= 1107296256)。官方规则是「只有 guilds / public_guild_messages / guild_members 默认有权限,其余事件必须申请;在鉴权时传了无权限的 intents,WebSocket 会直接关闭连接」。所以默认故意不含 direct(频道私信,需单独申请)——一个只做单聊/群聊的机器人如果默认索要它会连不上。需要用别的组合时,在通道的 intents 里填关键字(c2c,public_guild,direct,interaction,…)或十进制位掩码。
- 被动回复的官方约束(插件已按此实现):msg_id 有效期 5 分钟、同一条入站消息最多回复 5 次;msg_seq 从 1 递增(同一 msg_id + 同一 msg_seq 会被平台判为重复)。因此:长回复按 ~900 字切分成多条并各自带新 msg_seq(超过 5 条会截断并显式标注「已截断」);被动窗口过期(40034005/304103/40034128)时自动改发一条主动消息兜底;40054005(消息被去重)按「已送达」处理,避免把成功当失败重发。
- 「已连接」是可证伪的(与微信那次同一口径):网关的关闭码会翻译成中文并写上面板——4014 intent 无权限(附「去 q.qq.com 申请,或把 intents 改成已有权限的事件」的下一步)、4013、4914(已下架,只允许沙箱)、4915(已封禁)等不可重试的码会停止重连并把原因留在面板,而不是每 3~60 秒重试一次把真正的原因埋进日志;4009 等可恢复的码会带 RESUME(session_id + seq)重连,由网关补发断线期间遗漏的事件。
- 心跳 ACK 看门狗:网关会对每个客户端心跳回 op=11。连续两个心跳周期(含 5 秒宽限)收不到任何帧就认定为半开连接,强制断开重连——修掉「socket 还 OPEN、面板还是绿的、消息却进不来」这一类。
- 发送失败不再静默:发送响应会被真正解析(err_code / code,注意官方失败也可能返回 HTTP 200)。失败会抛出带中文原因的错误,由网关回发到同一会话并写进面板的通道状态行(最近一次消息处理失败:…)。
- 边界:群 / C2C 能力需在 q.qq.com 提审开通,未过审时接口报权限错误属正常;C2C/群消息为被动回复(需先用 msg_id 引用,无主动推送);AppSecret 是机密,勿提交进 Git。

QQ 真机探针
sh
pnpm qq-probe   [--ints c2c,public_guild] [--sandbox] [--seconds 90] [--no-reply]

不经过 DSH 宿主,直接把传输层指向真实开放平台跑一遍「取票 → 取网关 → WebSocket 鉴权 → 收消息 → 被动回复」,并把每一步的平台原话(含关闭码 / error code)打印出来;失败时按面板口径给出排查建议。它不会打印 AppSecret / AccessToken。用途与微信那次的 scripts/inbound-probe.mjs 相同:把「面板说连接中」拆成「卡在哪一步」。

Secrets:不要把真实值提交进 Git。.gitignore 已排除 cordis.local.yml / .env;切勿把 apiKey/appSecret/password 提交到会进入版本控制的通道记录里。

文件

| 路径 | 用途 |
| --- | --- |
| src/config.ts | Schemastery Config schema(旧版单 webhook 可调项,含 allowlist) |
| src/inbound.ts | 内嵌的 node:http webhook 服务器(按 URL 路径路由;仅在 handle() resolve 后 ack 202) |
| src/gateway.ts | 挂载到工作区的会话组装、live-agent 复用、rpcId 回复认领、allowlist / 去重 / 串行化 / 来源注入 / 投递重试 / 故障通知 |
| src/session.ts | 确定性的会话键推导:im-,按通道隔离并限定到显式配置的工作目录 |
| src/index.ts | 插件入口(name/inject/Config/apply + 生命周期 + GET /im-gateway/status 路由) |
| src/status-proto.ts | 实时通道状态的宿主↔客户端通信契约(无依赖;为何用路由而非 Remote 命名空间) |
| src/status-route.ts | 状态路由处理器(载荷投影、浏览器鉴权门、方法守卫) |
| src/channels/types.ts | 通道类型模型 + 状态(纯类型,客户端/宿主共享) |
| src/channels/schema.ts | 宿主侧 im-channels 设置 schema(SECRET 字段通过 role('secret'),外加插件级默认 cwd) |
| src/channels/manager.ts | 每个通道的连接生命周期、传输构建、实时状态快照 |
| src/transports/.ts | 每个通道一个真实适配器(http / email / cmcc / feishu / wechat / qq / qqbot),每个都为其运行时打上 channel 标记 |
| src/client/ | 浏览器端:可展开的插件卡片(ChannelsCard)包裹通道管理 UI(ChannelsSection)、傻瓜式模板、实时状态 + 本地编码二维码(qr.ts) |
| cordis.yml | 用于开发 / e2e 迭代的本地源覆盖(--patch) |
| cordis.patch.yml | 已发布的 bundle 层——按名称引用包(dsh-im-gateway → lib/index.js) |
| scripts/build.mjs | esbuild 构建:产出 lib/index.js(node)+ lib/client.js(browser)+ lib/vendor/lark-sdk.cjs(内置的飞书 SDK) |
| scripts/check-install-scripts.mjs | 构建守卫:若任何运行时依赖(传递性地)带有安装时脚本则失败 |
| scripts/smoke.mts | 本地冒烟测试(会话哈希、HTTP 路由、回复回调、CMCC 失败、内置飞书 SDK、微信二维码/存活检测、QQ 握手/关闭码/看门狗/发送、默认 cwd 优先级) |
| scripts/qq-probe.mts | QQ 真机探针(pnpm qq-probe  ):token → gateway → WS 握手 → 入站 → 被动回复,打印平台自身的应答 |
| lib/ | 已提交的构建产物——没有 prepare;git 安装时按原样挂载。每次改动 src/ 都要一起重新构建并提交 |
| lib/vendor/lark-sdk.cjs | 已提交的内置第三方(飞书 SDK,MIT)——由 scripts/build.mjs 生成,绝不手工编辑 |
| docs/channel-ui-design.md | 多通道设置 UI 的设计文档 |
| docs/2026-09-17-wechat-connected-but-mute.md | 微信「已连接但不回复」的完整证据链与修复记录 |
| docs/2026-09-17-qq-connect-diagnosis.md | QQ「连不上」的协议对账、根因与修复记录(含关闭码/错误码速查) |
| LICENSE | MIT 许可证 |
| README.md | 本文件 |

配置(通过 cordis.yml)

| 键 | 默认值 | 含义 |
| --- | --- | --- |
| host | 127.0.0.1 | 入站监听地址 |
| port | 8799 | 入站监听端口 |
| inboundPath | /im | Webhook URL 路径 |
| secret | '' | 可选的共享密钥;请求必须在请求头 x-im-secret 中携带它。为空 = 无鉴权。 |
| chatIdField | chat_id | 用于标识聊天的 Webhook JSON 请求体字段 |
| textField | text | 承载消息文本的 Webhook JSON 请求体字段 |
| senderField | sender_id | 可选的发送者 id 请求体字段(用于允许列表 + 来源注入) |
| allowlist | [] | 发送者允许列表(访问控制)。非空 ⇒ 只有这些 senderId 可以驱动 agent;其他 / 无发送者的请求会在一开始就被拒绝 |
| callbackUrl | (必填)* | 回复 POST 到的 URL |
| callbackChatHeader | x-im-chat-id | 回调中承载聊天 id 的请求头 |
| callbackSecretHeader | x-im-secret | 回调中承载密钥的请求头 |
| provider | '' | 模型 provider 路由覆盖(为空 = 运行时默认模型) |
| model | '' | 模型 id 覆盖(空 = 运行时默认模型) |
| maxTokens | 0 | 正数输出上限,或 0 表示默认 |
| agentPreset | '' | 创建时应用的可选 agent 预设 |
| cwd | '' | Agent 会话的可选工作目录(一个真实的 Harness 工作区)。仅作回退:频道自身的 cwd 和设置卡片的全局默认工作目录优先(未设置时为 ~/.dsh/im-workspace) |
| disposeAfterReply | false | 每次回复后释放 Agent(释放资源,丢弃上下文) |

⚠️ 只有 host/port/inboundPath/chatIdField/textField/senderField/allowlist/callbackChatHeader/callbackSecretHeader
以及类复选框字段是非敏感的接线配置。secret 和 callbackUrl 是部署密钥 ——
切勿提交真实值。将你的 cordis.yml 密钥保存在 .env/本地覆盖中,并排除在仓库之外。

安全

- 入站认证:设置 secret,使 webhook 仅接受携带
x-im-secret:  的请求。仅当端点有防火墙保护
且上游 IM 平台是唯一调用方时,才可留空。
- 发送方访问控制:设置 allowlist(按频道),使只有已知发送方
能驱动 agent。未授权(或无发送方)的消息会在任何 agent/工作区/模型副作用发生之前被拒绝。
- 入站加固:共享 webhook 将请求体上限设为 1 MiB(413)
并限制并发连接;密钥检查为常量时间;内部
错误详情会被记录,但绝不会在 5xx 响应中返回。
- 出站超时:每个回复回调 / 伴随 HTTP 调用都带有
AbortSignal.timeout,因此黑洞端点无法将聊天的
串行轮次卡住整个 undici 默认时长。
- 密钥管理:将真实的 secret 和 callbackUrl 排除在 Git 之外。
本仓库仅附带 secret: '' 和回环占位符 callbackUrl。
为真实值创建基于 .env 或仅本地的 cordis.yml 覆盖层。
- Agent 访问:该插件为每个外部聊天创建一个持久、附加到工作区的 Harness
Agent。用 secret + allowlist 保护端点,和/或
将其置于私有网络之后 —— 否则任何能访问它的人都能驱动
底层 agent(及其模型成本)。

用法

该插件以两种可互换形式提供:

- bundle(部署推荐)—— 按包名安装,加载构建后的 lib/index.js;
- 本地源码覆盖层(开发)—— 对 src/ 使用 --patch 以便快速迭代。

作为 bundle 安装

将 bundle 添加到某个 profile。构建后的 lib/ 已提交,且该包
没有 prepare/postinstall 脚本,因此安装时不会运行任何内容:
sh
dsh plugin --profile demo add github:you/dsh-im-gateway

无需 allowBuilds 条目 —— 在这台机器上或任何共享者的机器上都不需要。
pnpm ≥ 10 拒绝运行未经批准的依赖构建脚本并退出
非零,dsh plugin 会将其报告为安装失败(“将上面 pnpm 打印的确切键添加到 allowBuilds 下的 …”),因此运行时依赖闭包中任何一处多余的 postinstall 都会破坏所有用户的单命令安装。因此,本包保证该闭包中不含此类脚本:飞书 SDK——其硬依赖 protobufjs 附带一个纯装饰性的 postinstall——被内联到 lib/vendor/lark-sdk.cjs 中,而不是通过安装获取;并且 pnpm build 会运行 scripts/check-install-scripts.mjs,一旦任何运行时依赖(传递性地)新增 preinstall/install/postinstall 脚本,构建就会失败。已通过在完全没有 allowBuilds 部分的干净配置文件上安装本包进行验证:pnpm 以 0 退出。
贡献者规则: 由于 lib/ 已被提交,每次 src/ 变更都必须附带重新构建的 lib/(先 pnpm build 再提交)——否则分发版本将运行过期的打包产物。
如需单文件产物而非 Git 安装,请运行 pnpm pack 和 dsh plugin --profile demo add ./dsh-im-gateway-.tgz。

从需要 allowBuilds 的版本升级? 如果先前的安装失败已向你的配置文件的 pnpm-workspace.yaml(C:\Users\\.dsh\profiles\\pnpm-workspace.yaml)写入了 protobufjs: 占位符,请删除该行——protobufjs 已不再是依赖树的一部分——然后重新运行 add 命令。

该打包产物的层是 cordis.patch.yml,它插入带有合理默认值的 im-gateway 行。可从你自己的配置文件的 cordis.patch.yml 覆盖任意键(较后的层按行胜出,并替换整个 config,因此需重新声明每个键)。

加载本地源覆盖层(开发)

从 DSH 仓库根目录(在 run-from-source 路径之后),使用此覆盖层启动 Web UI:
sh
pnpm dsh web --patch /path/to/dsh-im-gateway/cordis.yml

cordis.yml 示例:
yaml
- insert:
- id: im-gateway
name: './src/index.ts'
config:
inboundPath: '/im'
secret: 'change-me'
chatIdField: 'chat_id'
textField: 'text'
senderField: 'sender_id'
allowlist: ['user-7']
callbackUrl: 'https://your-im-bridge.example/reply'
provider: 'deepseek'
model: 'deepseek-chat'

外部 IM → 网关(旧版 HTTP webhook)

设置 UI 是附加渠道的主要方式(见上文)。下面保留的旧版单一 HTTP webhook 路径用于向后兼容 / 无头环境。

将消息 POST 到 http://:/im:
json
{ "chat_id": "group-42|user-7", "sender_id": "user-7", "text": "你好" }

当设置了 secret 时,发送请求头 x-im-secret: 。网关在消息被接受后立即响应 202 { ok: true }——它不会等待模型回合。代理回复始终稍后通过回调到达(见下文)。
当设置了 allowlist 且 sender_id 不在其中(或缺失)时,消息会在一开始就被拒绝(仍会返回 202)——不会触发 agent 轮次,也不会有回复。

网关 → 外部 IM(出站回调)

收集到的回复会以 POST 方式发送到 callbackUrl(最多尝试投递 2 次;放弃时会记录 reply NOT delivered):

POST
x-im-chat-id: group-42|user-7
x-im-secret: change-me

{ "chat_id": "group-42|user-7", "text": "", "ts": 1710000000000 }

开发

该插件旨在运行中的 DeepSeek Harness 内部加载,后者已经提供了它所需的所有 @deepseek-ai/ 包(cordis、dsh-llm、dsh-session、dsh-agent、schemastery)。它们被声明为可选的 peer 依赖:不要自行安装它们——应从加载该插件的 DSH 运行时中解析它们。

构建

可分发的 bundle 使用 esbuild 构建(唯一的 devDependency),将 src/index.ts 打包为 lib/index.js,并将所有 @deepseek-ai/ 包保留为外部依赖(它们从宿主运行时解析):
sh
pnpm build          # build.mjs (3 artifacts) + the install-script guard
pnpm check:deps     # guard alone: scan the runtime dependency closure

pnpm build 会生成三个已提交的产物,然后验证安装流程:

| 产物 | 说明 |
| --- | --- |
| lib/index.js | node 部分——Cordis 插件入口(@deepseek-ai/、ws、nodemailer、imapflow、mailparser 保持为外部依赖) |
| lib/client.js | 浏览器部分——DSH 客户端模块 bundle |
| lib/vendor/lark-sdk.cjs | vendored 的飞书/Lark SDK(从已发布的 @larksuiteoapi/node-sdk devDependency 打包并压缩而来,内联了 protobufjs,头部保留了上游 MIT 文本) |

为什么要 vendoring 飞书 SDK。 @larksuiteoapi/node-sdk 硬依赖 protobufjs,而后者的 postinstall 脚本(它只是打印一条版本方案警告)会导致 pnpm 以 ERR_PNPM_IGNORED_BUILDS 中止安装,除非消费者将其加入 allowlist——而 dsh plugin 会把这一非零退出码变成一次失败的安装,并附带“手动编辑你 profile 的 pnpm-workspace.yaml”的提示。由于已发布的 SDK dist 本身已经是一个自包含的 bundle(其唯一的运行时 require 是 protobufjs/minimal),在这里将其 vendoring 一次,就能把该包——以及所有其他安装期脚本——从消费者安装的依赖树中移除。要升级它,请提升 @larksuiteoapi/node-sdk devDependency 的版本,运行 pnpm build,并提交重新生成的 lib/vendor/lark-sdk.cjs(版本记录在其头部)。传输层会从计算出的路径(src/transports/feishu.ts)惰性加载它,因此 node 部分永远不会内联它,插件启动也永远不会为此付出代价。

已提交的 lib/ 是消费者从 Git 安装时加载的内容。这里没有 prepare 脚本——Git 安装不会构建任何东西。每次修改 src/ 时都要一起重新构建并提交 lib/,以便分发的 bundle 保持
当前;pnpm check:deps 必须保持通过,因为单个传递性的
安装时脚本会悄无声息地破坏所有人的 dsh plugin add。

类型检查

要针对真实的 DSH 类型对 src/ 进行类型检查,请将此包放置(或链接)到
DSH 检出目录中,使其 node_modules 能够解析 @deepseek-ai/,然后运行:
sh
pnpm typecheck   # 或:npx tsc -p tsconfig.json --noEmit

tsconfig.json 从 node_modules 读取 @deepseek-ai/*,与任何 DSH
工作区包的做法完全一致——此仓库中没有任何硬编码路径。

实时加载(端到端)

在你的 DSH 仓库根目录(从源码运行路径)中,将 --patch 指向本仓库的
cordis.yml:
sh
pnpm dsh web --patch /path/to/dsh-im-gateway/cordis.yml

然后发送一条入站消息:
sh
curl -X POST http://127.0.0.1:8799/im \
-H 'content-type: application/json' \
-H 'x-im-secret: ' \
-d '{"chat_id":"some-chat","sender_id":"user-7","text":"你好"}'

202 确认会立即返回;代理的回复稍后通过配置的回调 URL 到达。由于会话是
工作区附加的,并通过稳定 id 恢复,重启 DSH 进程并在同一 chat_id 发送
另一条消息会继续同一对话,而不会发生 id 冲突。

上游仓库有新提交时邮件通知你(每天最多一封,无更新不打扰),随时一键退订。

同作者(masquerator-coder)的其他插件

💬 加入 DPharness 群聊

插件用法、部署报错、新插件第一时间同步——群里问,比一个人翻文档快。

点击加入 QQ 群
DPharness 群聊二维码,手机 QQ 扫码进群
扫码进群