# im-gateway
**Repository Path**: deepseek-harness/im-gateway
## Basic Information
- **Project Name**: im-gateway
- **Description**: im-gateway 是一个高性能即时通讯网关,支持高并发连接与消息路由,专为分布式IM系统设计,提供稳定、低延迟的消息传输能力。
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-08-25
- **Last Updated**: 2026-09-18
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# im-gateway
IM 网关:把飞书 / 企业微信 / 钉钉的机器人消息转发给后端 agent 处理,并把
回复推回 IM。处理方经**统一 AgentBackend 接口**挂接——内置 `dsh`(DeepSeek
Harness,通过 `deepseek-harness-sdk` Python JSON-RPC over stdio 驱动 runtime,
dsh 仓库零改动)与 `builtin`(未对接 agent 时的网关自处理);后续
claudecode / codex 等实现同一接口即可接入。方案背景见 `openspec/changes/`
与立项提案。
**当前:飞书长连接(多账号)+ 企微智能机器人长连接 + 钉钉 Stream 模式长连接**——
私聊或群里 @机器人说一句话,机器人经后端 agent 完成真实任务并回复;可同时挂多个
飞书自建应用、多个企微智能机器人与多个钉钉企业内部应用(混合部署亦可,每个
应用还可选不同后端)。
## 架构
```mermaid
flowchart LR
FS1["飞书应用 A
(ws 长连接)"] --> ADP["平台适配器
feishu.py / wecom.py /
dingtalk.py ×N
(每应用一条连接)"]
FS2["飞书应用 B
(ws 长连接)"] --> ADP
WC1["企微智能机器人
(ws 长连接)"] --> ADP
DT1["钉钉企业内部应用
(Stream 长连接)"] --> ADP
ADP --> POL["policy.py×N
每应用白名单(∪/bind运行期绑定)/@bot/限流
/memory 命令族识别(enabled时)"]
POL -->|"✓ /memory 命令族"| MEMC["memory.py
命令整段转发
(memory-cmd 执行器)"]
POL --> MED["media.py
媒体下载落盘(三平台)
处理方案=基础⨝按后端覆盖"]
MED --> RT["router.py
app_key:chat_id→session (SQLite)"]
RT --> RUN["runner.py
共享线程池+每键串行
(后端无关编排)"]
RUN -->|"prompt_hook:
(session,user) 首次注入"| INJ["MemoryInjector
resolve→context 两跳
失败静默/attempt-once"]
RUN -->|"completion_hook:
dsh turn 完成上报"| REP["MemoryReporter
载荷构造+退避重试
(memory-hook 执行器)"]
MEMC --> MGW[("memory-gateway
(软依赖·可停机)")]
INJ --> MGW
REP --> MGW
RUN -->|"AgentBackend 接口
(每应用可选后端)"| BKD["backends.py"]
BKD --> DSH["dsh 后端
DeepSeekHarness.run()
(read-only 沙箱)"]
BKD --> BTN["builtin 后端
无 agent 自处理"]
DSH -->|"回复"| RUN --> ADP
BTN -->|"回复"| RUN
```
模块规范见 `openspec/specs/`(feishu-adapter / wecom-adapter / dingtalk-adapter /
session-routing / access-policy / dsh-runner / agent-backend / multi-app-config
等 capability)。
## 快速开始
### 1. 飞书开放平台建自建应用(约 5 分钟)
1. [开放平台](https://open.feishu.cn/) → 创建企业自建应用,记下 `App ID` / `App Secret`;
2. 添加「机器人」能力;
3. 事件与回调 → 订阅方式选 **长连接**(无需公网);
4. 订阅事件 `im.message.receive_v1`(接收消息);
5. 权限管理开通:`im:message`(读)、`im:message:send_as_bot`(回复);
6. 发布版本并等待企业管理员通过;
7. 把机器人拉进测试群(或在 IM 中直接私聊)。
### 2. 安装与配置
```bash
python3.11 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
cp config.example.yaml config.yaml # 填 app_id / 白名单 chat_id / user_id
```
环境变量(必需):
```bash
export DEEPSEEK_API_KEY=sk-... # 透传给 dsh runtime,绝不写进 config
```
白名单里的 `chat_id` / `user_id` 引导期可先用 `--check-config` + 日志摸索:
非白名单消息会记 debug 日志(含 chat_id),把它填进白名单即可。之后的日常
扩群不必改 config——admin(基线 `user_ids` 内用户)在新群 @机器人 `/bind`
当场生效,见「[运行期绑定](#运行期绑定bind-命令族)」。
**已部署网关要新增机器人**(任一已有平台的新 app):用 `/add-bot` 工作流
(`.claude/commands/add-bot.md`)引导——平台创建 → config/secret 手术 →
重启验证 → 真连验收全流程,不改代码。全新平台才需要开发适配器(见「开发」)。
### 3. dsh runtime 的两种模式
`dsh.launch_args` / `dsh.runtime_cwd` 留空时走 **exe 模式**(PyPI
`deepseek-harness-runtime-bin` 打包的单文件 runtime;当前 PyPI 仅有占位版
`0.0.0.dev0`,不可用)。在此之前用**源码模式**:本机有 deepseek-harness
checkout(已 `pnpm install`)时,在 `config.yaml` 里:
```yaml
dsh:
runtime_cwd: "~/GitProject/deepseek-harness"
launch_args: ["./node_modules/.bin/tsx", "packages/examples/jsonrpc-demo/src/bin.ts"]
```
已验证:源码模式 + 本仓库 `cordis/im-gateway.cordis.yml` 跑通
`harness.run("回复 ok") → "ok"`(`scripts/smoke_sdk.py`)。
### 4. 启动
```bash
python -m im_gateway.main # 阻塞在首个应用的长连接
python -m im_gateway.main --check-config # 只自检配置/路径/环境变量(按平台计数)
```
群内 `@机器人 列出当前目录的文件`,或私聊直接发。命令:`/new` 开新会话、
`/status` 查看当前会话;群绑定命令族 `/bind` / `/bind list` / `/unbind`
见下节。企微侧行为一致:慢任务先推「处理中」占位、完成后
同一条消息全量刷新落定(伪流式)。
## 运行期绑定(/bind 命令族)
config 白名单是冻结基线;扩群不用改配置重启——admin 在群里当场绑定:
| 命令(群内 @机器人 后接) | 行为 |
| --- | --- |
| `/bind` | 绑定当前群(永久);重复绑定幂等刷新 |
| `/bind 7d` / `/bind 24h` / `/bind 30m` | 绑定当前群,时限 = 数字 + 单位(天/时/分),到期自动失效 |
| `/bind list` | 列生效名单:config 基线项(不可解)+ 运行期绑定(含绑定人、剩余时限) |
| `/unbind` | 解除当前群的运行期绑定;config 基线项不可经 IM 解除 |
规则(openspec/changes/im-bind-command):
- **admin 只从 config 基线 `user_ids` 派生**(引导名单内用户即管理员);
运行期绑定的群成员**不是** admin——一次绑定不产生新的绑定权,防止自我
蔓延提权。非 admin 发 bind 家族:静默忽略 + 审计日志(不回复、不写库)。
- v1 只绑群聊坐标:私聊里发 bind 家族回用法提示。
- 绑定命令不占限流桶(与 `/new` `/status` 一致)。
- 存储在 `data/bindings.db`(SQLite,与 router.db 分离):网关重启后绑定
仍生效;文件损坏自动降级回 config 基线(fail-closed),删文件即整体
清空运行期绑定。
## 配置项(config.yaml)
| 键 | 默认 | 说明 |
| --- | --- | --- |
| `feishu.app_id` / `app_secret` | — | 单应用写法的凭据;secret 可改用环境变量 `IM_GATEWAY_FEISHU_APP_SECRET` |
| `feishu.bot_name` | `dsh` | 群 @ 前缀识别用 |
| `whitelist.chat_ids` / `user_ids` | —(必填非空) | 单应用模式的白名单;任一命中即放行,其余静默忽略 |
| `feishu_apps` | 空 | 多应用列表(见下节);与 `feishu` 互斥,每项内联 whitelist |
| `wecom_apps` | 空 | 企微智能机器人列表(见下节);可与任一飞书写法并存 |
| `dingtalk_apps` | 空 | 钉钉企业内部应用列表(见下节);可与飞书/企微写法并存 |
| `backend` | `dsh` | 全局默认后端(统一 AgentBackend 接口;内置 `dsh`/`builtin`,未知名启动即拒) |
| `<应用条目>.backend` | 空 | 每应用覆盖后端(如某 app 走 `builtin`,其余走全局默认) |
| `dsh.provider` / `model` | `deepseek-official` / `deepseek-v4-flash` | 透传 SDK |
| `dsh.cwd` | `~/.im-gateway/workspace` | agent 工作目录 = read-only 沙箱根(媒体落盘根) |
| `dsh.session_root` | `~/.im-gateway/sessions` | dsh 会话 JSONL 留痕 |
| `dsh.cordis` | `cordis/im-gateway.cordis.yml` | 无人值守权限组合 |
| `dsh.runtime_cwd` / `launch_args` | 空 | 源码模式启动参数(见上) |
| `limits.max_global` | 4 | 全局并发 prompt 上限(线程池,**全应用共享**) |
| `limits.per_chat_qps` | 1.0 | 每 (应用, chat) 限流(token bucket,突发 2) |
| `limits.idle_hours` | 12 | 闲置超时换新会话 |
| `limits.ack_threshold_seconds` | 3 | 超过该时长先回「处理中」 |
| `media.max_mb` | 50 | 入站文件大小上限(MB),超限不下载、礼貌回复 |
| `media.vision.model` | `deepseek-v4-flash-vision-exp` | vision 处理器模型(项目内视觉统一模型) |
| `media.vision.base_url` | `https://api.deepseek.com` | OpenAI 兼容端点(可切中转/自建/其他厂商) |
| `media.vision.api_key_env` | `DEEPSEEK_API_KEY` | 视觉凭据的环境变量名(只走 env,不落盘) |
| `media.vision.timeout_seconds` | 30 | 视觉调用超时;超时降级为纯路径 prompt |
| `media.vision.max_tokens` | 4096 | 视觉回复预算(含推理);过小会被 reasoning 耗尽致 content 空 |
| `media.vision.prompt` | `请详细描述这张图片的内容。` | 视觉提问 |
| `media.processors` | `{image: none, file: none}` | 基础方案:kind→处理器映射(缺省纯传输;内置 `vision`/`none`) |
| `media.backend_processors` | `{dsh: {image: vision, file: vision}, builtin: 同}` | 按后端覆盖处理方案(「是否代看」按类型×后端配;file 由内容嗅探门控只对真实图片生效) |
| `data_dir` | `data` | router.db 与日志目录 |
| `memory.enabled` | `false` | 记忆对接总开关(整段缺省即关停:不建客户端、policy 不识别 `/memory`,杜绝半开) |
| `memory.base_url` | `http://127.0.0.1:8800` | memory-gateway 基地址(同机部署前提) |
| `memory.timeout_ms` | `5000` | 注入两跳总预算(毫秒,须为正数;命令转发同用) |
| `memory.token_env` | `MEMORY_GATEWAY_TOKEN` | token 的环境变量**名**约定——token 值只走 env,绝不落 config;enabled 且缺变量时启动阻断 |
| `memory.retain.enabled` | `true` | 沉淀腿开关:`memory.enabled=true` 时 task_complete 自动上报(D9 全自动闭环;可独立关停灰度,注入与 `/memory` 命令腿不受影响) |
| `memory.retain.timeout_ms` | `30000` | 单次上报尝试预算(毫秒,须为正数;retain 同步含 mg 引擎提取,宁大勿小,独立于注入 `memory.timeout_ms`) |
| `memory.retain.max_message_chars` | `8192` | 上报 `message`(注入前任务原文)截断上限(超限截断并日志标注) |
| `memory.retain.max_result_chars` | `16384` | 上报 `result`(回复全文)截断上限(对端另有 256KB 载荷硬上限 413 兜底) |
## 多飞书账号
同一网关挂多个飞书自建应用(两个企业、或生产/测试双机器人),共用一套 dsh
执行池与运维面。把 `feishu:` + 顶层 `whitelist:` 换成 `feishu_apps:` 列表:
```yaml
feishu_apps:
- app_id: "cli_aaaaaaaaaaaaaaaa"
bot_name: "dsh-prod"
whitelist:
chat_ids: ["oc_prod_group"]
user_ids: ["ou_me"]
- app_id: "cli_bbbbbbbbbbbbbbbb"
bot_name: "dsh-test"
whitelist:
chat_ids: ["oc_test_group"]
user_ids: ["ou_me"]
```
要点:
- **每应用独立**:一条 ws 长连接、白名单、@bot 名、限流桶;`app_secret`
留空时读环境变量 `IM_GATEWAY_FEISHU_APP_SECRET_`(APP_ID 转大写、
非字母数字→下划线)。
- **会话隔离**:路由键为 `app_id:chat_id` 复合键——不同应用的 chat_id 空间
独立、可能碰撞;同名 chat 在两个应用里是两个独立会话,`/new` `/status`
互不影响。
- **共享总闸**:`limits.max_global` 是全应用共享的并发上限(dsh 单 runtime
多 session 并行已实测);多账号不放大执行侧预算。
- **升级提示**:从单应用版本升级后路由键带上了 app 前缀,旧 router.db 的
裸 chat_id 键不再命中,等于一次性全员换新会话(SDK 本无跨重启 resume,
只损失 session_id 数值稳定性,JSONL 留痕不受影响)。
## 接入企业微信(智能机器人 · 长连接)
企微走**智能机器人 WebSocket 长连接**(无需公网端点、无回调加解密),与
飞书长连接同构;`wecom_apps` 可与任一飞书写法并存,混合部署时每应用
(app_id / bot_id)独立连接、白名单与限流,路由键 `bot_id:chat_id` 与飞书
空间天然隔离。
### 1. 企微管理后台创建智能机器人
1. 管理后台 → 智能机器人 → 创建,记下 **BotID**(`aibty-` 前缀)与
**长连接专用 Secret**(≠回调模式的 Token/EncodingAESKey);
2. 按需配置机器人可用范围(可见的人员/群)。
### 2. 配置
```yaml
wecom_apps:
- bot_id: "aibty-xxxxxxxxxxxxxxxx"
secret: "" # 留空读环境变量 IM_GATEWAY_WECOM_SECRET_
bot_name: "dsh" # 必须与企微端显示名一致(群 @ 前缀识别)
# welcome: "你好,我是 dsh" # 可选:进入会话 5s 内的欢迎语
whitelist:
chat_ids: [] # 群聊 chatid(wr 开头)
user_ids: [] # 单聊填对方 userid(wor- 前缀加密 ID,从日志抄)
```
要点:
- **bot_name 配错的表现是群消息静默**(@ 前缀识别失败 → 按白名单外忽略,
debug 日志可查)。
- **语音消息按 ASR 文本当文本**处理;图片/文件/视频/mixed 媒体已接入
(单聊收即下载解密,见「媒体消息」节;image/file/video 平台仅单聊
下发)。
- **伪流式回复**:慢任务 3s 先推「处理中…」占位(stream 首帧),完成后
同 stream id 全量刷新 + finish 落定;流式失败面(发送异常/被拒/超 10 分钟
窗口)自动回退 markdown 单发,回复失败只记日志不崩溃。
- 冒烟:`.venv/bin/python scripts/smoke_wecom.py --raw`(echo 模式,不需要
DEEPSEEK_API_KEY;`--raw` 的原始帧日志已自动打码 secret)。
## 接入钉钉(企业内部应用 · Stream 模式)
钉钉走**Stream Mode 长连接**(无需公网端点、无回调加解密),与飞书/企微
长连接同构;适配器为自实现轻量客户端(`websocket-client` + stdlib
`urllib`,不引入官方 asyncio SDK),`dingtalk_apps` 可与任一飞书/企微
写法并存,路由键 `client_id:conversationId` 与其他平台空间天然隔离。
### 1. 开放平台创建企业内部应用机器人
1. [开放平台](https://open.dingtalk.com/) → 应用开发 → 创建企业内部应用,
记下 `Client ID`(`ding` 前缀的 AppKey)与 `Client Secret`;
2. 添加「机器人」能力;
3. 机器人配置页 → 消息接收模式选 **Stream 模式**(默认 HTTP 回调,务必改);
4. 按需发布并等待管理员审核,把机器人拉进测试群(或私聊)。
### 2. 配置
```yaml
dingtalk_apps:
- client_id: "dingxxxxxxxxxxxxxxxx"
client_secret: "" # 留空读环境变量 IM_GATEWAY_DINGTALK_CLIENT_SECRET_
bot_name: "小钉" # 须与钉钉端机器人显示名一致(群 @ 定向重组用)
whitelist:
chat_ids: [] # conversationId(cid 开头,单聊/群聊同构)
user_ids: [] # senderStaffId(纯数字,从日志 INBOUND 行抄)
```
要点:
- **协议**:HTTP `connections/open`(凭据换 endpoint+ticket)→ ws
`endpoint?ticket=…`;每帧必回 ack(同 messageId、data 原样回带);
保活是 ws 协议层 ping(默认 60s)+ ack 服务端应用层心跳帧(已内置处理);
断线指数退避重连。
- **群 @ 定向**:钉钉群聊 @ 是消息实体,**不进** `text.content`(真连
确认)——适配器按 body 的 `isInAtList` 重组 `@bot_name ` 前缀供准入策略
识别(未 @ 机器人则不响应);`bot_name` 配错的表现是群消息静默。
- **回复**:HTTP POST 每条消息携带的 `sessionWebhook`(会话级临时 webhook,
免鉴权)。**无原地刷新能力**——慢任务的「处理中」占位与最终结果是两条
独立消息(与飞书同构;企微式伪流式不适用)。
- **文本与文件**:文本、文件(downloadCode 收即下载,见「媒体消息」节)、
富文本(取文本段,群 @ 定向同 isInAtList 重组)支持;其余类型记日志拒收。
- 冒烟:`.venv/bin/python scripts/smoke_dingtalk.py --raw`(echo 模式,不需要
DEEPSEEK_API_KEY;`--raw` 的原始帧日志已自动打码凭据/webhook)。
## 媒体消息(文件与图片,三平台)
用户直接给机器人发文件/图片(飞书 `file`/`image`、钉钉 `file`/`picture`、
企微 `image`/`file`/`video`/`mixed`),网关**收即下载**(平台下载句柄都是
临时凭据)落盘到 `/media-in/<日期>/`(文件名 sanitize 防路径
穿越),agent 收到的 prompt 是「文件名 + 大小 + 来源 + 绝对路径(+ 用户
附言 + 处理器产出)」的纯文本,随后自行读文件处理。媒体消息与文本消息走
**完全相同的**准入路径(白名单/@ 定向/限流/命令/会话路由)——群里的文件
消息同样只有 @ 机器人(钉钉 isInAtList)或私聊才会被处理。
企微长连接方式的媒体链路特殊(官方 path/101834):载荷是 `{url, aeskey}`
(url 免鉴权 GET、**5 分钟有效**;密文 AES-256-CBC 解密,key 由 aeskey
解得——真连实证为 43 字符 base64 无填充、解码即 32 字节,与官方文档
「32 字节原文」表述不符、两形态都容,IV 取密钥前 16 字节),网关收到
回调后即时下载解密;**平台不给文件名**(落盘名由句柄派生、类型由内容
预分析判定);`mixed` 图文混排取文本段为附言 + 首张图片(多图仅取首张);
语音消息平台已转写文本、按普通文本处理。
- 超过 `media.max_mb`(默认 50MB)的文件不下载,直接回复「文件太大」;
- 下载失败(临时链接过期/权限不足/网络错误)回复失败文案,网关与长连接
不受影响,重新发送即可;
- 飞书需在开放平台给应用额外开通 `im:resource` 读权限(下载 message
resources 用),否则文件消息解析正常但下载必失败;
- 飞书 `post` / 钉钉 `richText` 富文本取文本段按文本处理,内嵌图片忽略;
- **图片视觉**(M2):生效方案为 vision 的后端,图片落盘后由 vision 处理器
(`media.vision.*` 全参可配,默认 `deepseek-v4-flash-vision-exp`)生成
内容描述直接附入 prompt,agent 拿到即答;builtin 后端则描述直接作为
回复。视觉调用失败/超时自动降级为纯路径 prompt,回复不受影响;
- **处理方案两级映射**:`media.processors` 是基础方案(缺省纯传输——网关
不代看,理解归后端),`media.backend_processors[<后端名>]` 按后端能力
覆盖——不同 agent 能力不同,是否代看按「媒体类型 × 后端」配。缺省给
dsh(主模型纯文本)与 builtin 补偿 `image/file→vision`;未来多模态后端
(claudecode 等)保持基础方案自己读图即可。`backend_processors.dsh.image:
none` 一行配置即可关闭代看;
- **内容预分析**:平台消息类型只做配置映射键,处理器按**文件内容**判定
([puremagic](https://pypi.org/project/puremagic/) 魔数库优先、扩展名
兜底;图片判定对齐 vision 接口能力面 jpeg/png/gif/webp)——「以文件发
的图片」(扩展名缺失/失真)同样代看,文档/压缩包/音视频(含伪装成
.jpg 的)被拦下纯落盘;嗅探类型记入 artifact 供日志/回执/后续处理器共用;
- **处理器接口**:落盘后的增值处理是 SPI(`processors.py`)——`kind →
处理器名` 映射即切换开关,内置 `vision`/`none`;后续 pdf/xlsx 提取器、
CLI 代理等实现 `MediaProcessor` 并登记 `REGISTRY` 即可被配置引用;
- **用户附言**:企微 `mixed` 的文本段等「随媒体发送的文字」以「用户附言」
行进 prompt(适配器占位文本如 `[image]` 不重复附入)。
## 处理后端(统一 AgentBackend 接口)
「谁处理消息」是网关的一等接缝:一切处理方实现 `AgentBackend`
(`run(session_id, RunRequest) -> str` + `close`),流水线(准入/媒体/路由/
并发编排/回复)后端无关。全局 `backend:` 选默认,每个应用条目内 `backend:`
可覆盖(同名单例共享,dsh 单 runtime 复用语义不变):
- **`dsh`**(默认):DeepSeek Harness 常驻 runtime,参数见 `dsh:` 段;
- **`builtin`**:未对接 agent 时的网关自处理——不重复实现视觉,直接消费
该后端生效处理方案的产出(图片描述即回复;文件回「已收到 + 路径」回执;
纯文本回提示文案),不拉 runtime 进程;
- **新 agent 接入**(claudecode / codex / openclaw / workbuddy…):实现
`AgentBackend` + 在 `backends.py::BACKENDS` 登记,配置引用其名即可,
流水线零改动(多模态后端可直接读 `RunRequest.artifact` 原文件)。
视觉处理在 `_media_exec` 单线程内、提交后端之前执行:图片的「处理中」
回执要等视觉调用完成(≤ `timeout_seconds`)才可能出现,慢视觉模型下用户
侧表现为延迟明显——届时调小超时或关闭该后端的 vision 方案(已知限制 20)。
## 权限模型(无人值守)
IM 里没人点批准,权限在 `cordis/im-gateway.cordis.yml` 一次性预置:
- `dsh-sandbox-policy: mode: read-only` —— 默认最小权限:agent 可读文件、跑
只读命令,一切写入被沙箱拒绝;
- `dsh-user-approval: policy: never` —— 确定性自动拒绝(fail-closed);
- 要让 agent 写文件:改 `mode: workspace-write` 并自担风险(README 不提供
更宽的默认)。
## 记忆对接(memory-gateway)
跨会话、跨平台的用户规则/背景记忆由独立服务 memory-gateway 提供(归一身份 +
规则/记忆注入 + `/memory` 命令面 + 任务终态自动沉淀;`../memory-gateway/`,
对接契约见其仓 `docs/im-gateway-memory-integration.md`、
`openspec/specs/im-command-face/` 与 `openspec/specs/task-hook-face/`)。
网关侧是**软依赖**:`memory:` 段缺省或 `enabled=false` 时全链零装配,行为与
无此能力逐字节一致(回滚 = 删配置段重启,无残留状态)。
### 启用步骤
1. 部署并起好 memory-gateway(默认同机 `127.0.0.1:8800`,先起谁都可以);
2. 网关 `config.yaml` 配 `memory:` 段(`enabled: true`;`base_url`/`timeout_ms`
一般用默认,见配置项表);
3. `export MEMORY_GATEWAY_TOKEN=…`(与 memory-gateway 服务侧同值;**token 只走
环境变量**,不落 config/日志/消息体——enabled 且变量缺失时 `--check-config`
与启动直接阻断);
4. 重启网关。验证顺序:先私聊 `/memory whoami` 验命令腿,再发普通消息验注入腿
(日志出现 `memory 注入已拼装` 即生效,只记注入段长度不落记忆内文),最后
正常对话验沉淀腿(memory-gateway 侧出现 `source_task=imt-…` 的留档即上报
生效)。
### 行为
**注入腿**(仅 `dsh` 后端;其他后端不注入——防未来双后端双重注入):
- 每会话每用户**首次**消息提交后端前,网关按消息三元组(平台/app/用户)调
memory-gateway `resolve → context` 两跳,把返回的记忆上下文包在
`…` 前缀里拼到
prompt 头部;内文**原样透传不改写**(分级标注是 memory-gateway 侧防提示
注入设计,网关改写 = 拆防御)。
- attempt-once:首条**尝试过**(发起过调用)即标记,同会话成败均不再重试;
`/new` 或闲置换新会话自然重置。
- 群聊多用户各注入各的(外层 `user` 标注区分归属)。
**命令族**(`memory.enabled=true` 时识别;不进 agent、不占限流桶):
| 命令 | 行为 |
| --- | --- |
| `/memory` | 显示用法 |
| `/memory whoami` | 查本人记忆身份(mem_uid + 绑定身份概要;首次自动开户并如实标注) |
| `/memory forget <关键词>` | 按关键词删除本人记忆(命中计数入回复) |
| `/memory forget all` | 清空本人全部记忆(须显式 `all`,无参仅回用法防误删) |
| `/memory bind ` | **仅 admin**(config 基线 `user_ids`):把本人身份绑定到目标记忆户(跨平台记忆合并) |
子命令解析、权限矩阵与审计都在 memory-gateway 侧执行,网关只**整段转发**
(连写如 `/memoryx` 不识别,按普通消息进 agent)。非 admin 发 bind:静默无
回复(ignored 审计在 memory-gateway 侧落行)。
**沉淀腿**(task_complete 自动上报,add-memory-retention-hook;
`memory.enabled=true` 时默认开启,`memory.retain.enabled=false` 可独立关停;
仅 `dsh` 后端——防双报与注入 D8 同判据):
- dsh 后端 turn **正常完成**后,网关向 memory-gateway
`POST /v1/hooks/task_complete` 自动上报任务终态(mg 侧按规则门留档 →
引擎提取 → 跨 IM 注入,「飞书说的、钉钉记得」全自动闭环)。载荷字段:
`task_id`(`imt-` 前缀 turn 级唯一,重试复用同值幂等)、`session_id`(本
turn 路由会话)、`agent=dsh`、`status=completed`、`source=im-gateway`、
`end_user_ref`(发言者三元组,mg 端幂等自动开户)、`message`(**注入前**
任务原文——`` 前缀是 mg 自产文本,回流留档即污染,网关
在 prompt_hook 替换前留底原文)、`result`(回复全文);超配置上限截断并
日志标注(载荷内不加截断标记字段)。
- fire-and-forget:完成钩子内**零 IO**(构造载荷后 submit 即返回),派发走
独立 `memory-hook` 单线程执行器——上报不进回复关键路径、不占 per-chat 锁,
回复时序无上报依赖。
- 响应分治:200(`retained`/`skipped`)均成功终态不重试(skipped 是正常业务
分支,status/reason 记 INFO);401/422/413 契约/凭据/超限错误不重试记
ERROR;5xx/超时退避重试至多 2 次(**同 `task_id`** 重投,mg 以其幂等去重,
引擎零双写);超限放弃记 ERROR(v1 无持久化重试队列,接受丢失)。
异常上抛的 turn 零上报(v1 恒 `completed`,见已知限制 26)。
### 失败语义(软依赖纪律)
| 场景 | 行为 |
| --- | --- |
| memory-gateway 停机 / 超时 / 5xx + 普通消息 | 注入静默跳过(WARN 日志可查),消息照常处理 |
| memory-gateway 停机 / 超时 / 5xx + `/memory` 命令 | 回「记忆服务暂不可用,请稍后再试。」(命令是显式意图,不静默吞没) |
| memory-gateway 停机 / 超时 / 5xx + 正常完成 turn | 沉淀上报后台退避重试至多 2 次后放弃(ERROR 留痕),回复照常、消息面零阻塞 |
| 两侧重启互不影响 | 注入幂等(网关无本侧记忆状态),memory-gateway 恢复即自动生效 |
联调冒烟:`scripts/smoke_memory.sh`(真机双栈:独立 memory-gateway + 本仓
Gateway 装配腿;场景与断言口径见脚本头注)。
## 已知限制
1. **网关重启不恢复会话上下文**:SDK 线协议未暴露跨进程 resume(仅
`initialize/session/prompt/shutdown`)。网关存活期间同一 chat 复用同一
`session_id` 续接会话(含持久 bash 的 cwd/env);重启后同 id 在新 runtime
进程内是全新会话,历史 JSONL 仍在 `dsh.session_root` 可追溯。
2. **无审批回路**:runtime 不会向网关发 approval 请求(SDK `respond()` 为
预留死能力),全部裁决由 cordis.yml 的沙箱 + never 策略预置承担。
3. **飞书 ws 客户端无优雅停机**:`lark.ws.Client` 无 stop API,Ctrl-C 直接
断连;在途 prompt 的结果会丢失(路由表与留痕不受影响)。
4. **旧进程杀不透会抢消息**(联调实测):外层 shell 被杀而 python 孤儿进程
仍持有长连接时,新旧两个实例并存、飞书事件被旧实例消费,表现为「改了
配置重启但行为不变」;多应用下是每应用一条连接,残留更隐蔽。启动前先查
`ps -eo pid,command | grep "[P]ython -m im_gateway.main"`,有残留先
`kill -9`。**restart 也有垂死窗口**(2026-08-27 实测):`systemctl
restart` 后旧进程的 shutdown 已关 store/runner 但进程滞留(飞书 ws
无优雅停机,systemd 默认等 90s),窗口内它继续消费事件并回复误导性
结果(如 `/bind` 回「绑定存储不可用」、普通消息 allow 后无回复);
缓解:unit 配 `TimeoutStopSec=3s`(servertest 已生效),窗口压到 3s。
5. PyPI 的 `deepseek-harness-sdk==0.0.0.dev0` 为占位发布,当前需本地 checkout
`pip install -e ~/GitProject/deepseek-harness/python/sdk --no-deps` 安装。
6. **lark-oapi ws 客户端模块级单 event loop**(1.7.3,联调实测):多个 ws
Client 并发 `start()` 会对同一 loop 重入报 "This event loop is already
running"。网关的绕法(`adapters/feishu.py::_ws_client_class`):主线程用
原生模块,其余线程各用 importlib 装一份模块私有副本(各自独立 loop)。
SDK 升级若改为按实例管理 loop,该绕法自动退化为常规导入。
7. **企微媒体已接入(add-wecom-media),平台侧范围限制照单全收**:
image/file/video/voice 仅单聊可达(mixed 本身群聊可用);平台限文件/
视频 100MB;**回调不含文件名**(落盘名由句柄派生,类型靠内容预分析);
mixed 多图仅取首张;`quote` 引用内容忽略;语音按企微侧 ASR 文本当文本
处理。媒体下载 url 5 分钟有效,网关收到回调即下载解密(AES-256-CBC),
超时未处理需重发。真连已验证:单聊文件(含文件形态图片→vision);mixed
的附言+首图重组有单测覆盖、真连待自然遇到时验证(实测合并转发聊天记录
≠mixed——它不在 aibot 支持的类型面内,企微平台直接拦截并代答「不支持
理解此类型消息」,不会到达网关;平台侧有兜底,无静默丢消息)。
8. **企微限频 30 条/分钟/会话**:伪流式每条消息占 2 帧(占位 + 落定),
持续入站 >15 条/分钟会触顶;默认 `per_chat_qps=1` 下人工使用打不满。
9. **企微流式 10 分钟窗口**:首次推送起 10 分钟内必须 finish,否则平台
自动结束该流;网关在 ~9.5 分钟仍未落定时提前回退 markdown 单发。
10. **企微同 bot 多实例互踢**:一 bot 一连接,同 bot_id 起第二个网关实例
会把前一个踢下线(配置层已拦同配置重复 bot_id,多进程部署需自行保证)。
11. **依赖命名易混**:企微用 `websocket-client`(import 名 `websocket`),
与 lark-oapi 传递依赖 `websockets`(asyncio 库,import 名 `websockets`)
是两个包,并存无冲突,排查 import 问题时注意区分。
12. **钉钉限频 20 条/分钟/机器人,超限罚 10 分钟**(比企微严:不是丢弃而是
惩罚性禁言):回复走 webhook 通道,「处理中」+ 最终结果占 2 条/消息,
持续 >10 消息/分钟会触顶;默认 `per_chat_qps=1` 人工使用打不满。
13. **钉钉 sessionWebhook 是会话级临时凭据**(社区经验约 90 分钟有效):网关
收到消息后秒级回复不受影响;极端拖延的回复会因过期失败(记日志,下一条
消息自带新 webhook 自动恢复)。
14. **钉钉无原地刷新**:sessionWebhook 每次 POST 即一条新消息,「处理中」
占位与最终结果是两条独立消息(企微式伪流式不适用;AI 卡片流式需
accessToken + 卡片 OpenAPI,未引入)。
15. **钉钉群内回复不 @发送者**:webhook 回复体不带 @(`reply(ref,text)` 无
发送者上下文),群内表现为普通消息。
16. **钉钉同 app 多实例互踢**:一应用一条 Stream 连接,同 client_id 起第二个
网关实例会把前一个踢下线(配置层已拦同配置重复 client_id,多进程部署需
自行保证)。
17. **共享 workspace 的媒体可见性**:单 dsh runtime 单 cwd,A 会话收到的
文件落在共享的 `media-in/` 下,其他白名单 chat 的会话同可读。单操作者
白名单模型下可接受;需要硬隔离时须 dsh 侧支持多 cwd(不在当前范围)。
18. **媒体下载是临时凭据链**:飞书 file_key 与钉钉 downloadCode/下载 url
均短期有效,网关收即下载、串行执行;若进程重启恰逢在途文件消息,该
文件需重新发送(回复失败文案提示)。
19. **钉钉媒体链路已真连验证(picture 路径)**:messageFiles/download 的
`robotCode` 以 `client_id`(AppKey)同值发送实测可用;`file` 与
`picture` 共用同一下载原语(仅 downloadCode 字段位置不同)。richText
的 @ 定向行为未验证。
20. **视觉处理在提交前串行执行**:vision 处理器在媒体单线程内、提交后端
前按该应用后端的生效方案调用——图片消息在视觉完成
(≤ `media.vision.timeout_seconds`)前既无回复也无「处理中」回执;
视觉失败静默降级纯路径 prompt(日志 warning 可查)。视觉凭据复用
网关环境变量(默认 `DEEPSEEK_API_KEY`),属网关侧新增网络调用面。
21. **`bot_name` 配错的群静默仅限企微/钉钉**(2026-08-27 飞书实测修正):
飞书群 `@` 以占位符(`@_user_N`)到达,policy 剥占位符即可,机器人
实际显示名与 config `bot_name` 不一致不受影响;企微(`@名字` 进文本)
与钉钉(`isInAtList` 重组前缀)才依赖字面匹配。另:拉 bot 进群会触发
`im.chat.member.bot.added_v1`,lark SDK 无处理器报 `processor not
found` ERROR——无害,日志噪音,后续可注册 no-op 消音。
22. **记忆面软依赖 + attempt-once 的停机窗口代价**:memory-gateway 停机不阻塞
消息面,但停机窗口恰逢某用户会话首条消息时,该用户**本会话**无注入——
失败也计入 attempt-once(防停机期每条消息都白等满 `memory.timeout_ms`),
显式 `/new` 换新会话即恢复重试;命令腿不受 attempt-once 影响(每次转发
都有回音或不可用文案)。
23. **`display_name` v1 不透传**:resolve/im_command 载荷的该可选字段网关不传
——IM 侧 user_id 是平台加密 ID(`ou_*`/`wor-*`/staffId),无展示价值;
whoami 回复已含身份概要。留 v1.1(平台事件带用户名时补透传)。
24. **记忆身份的稳定性以平台 ID 稳定为前提**:identity 以三元组(平台/app/
user_id)解析,企微单聊 `wor-` 加密 ID 若平台侧重置即生成新身份(旧记忆
不自动跟随);飞书 `ou_*` 与钉钉 staffId 同理。跨平台记忆合并走
`/memory bind`(admin)。
25. **unified backend 形态的记忆面待后续**:当前注入仅挂 `dsh` 后端(防双重
注入,切换判据 = backend 名);B3 unified backend(载荷带 end_user_ref、
任务终态经 task_hook 自动沉淀记忆)在 memory-gateway 侧变更合做后再接。
26. **沉淀腿 v1 边界**(add-memory-retention-hook):task_complete 上报仅覆盖
**正常完成**的 turn(`status` 恒 `completed`;异常 turn 零上报——mg 规则门
对 failed 本就纯 skip,报了是无意义流量);仅挂 `dsh` 后端(防双报与注入
同判据);重试耗尽即放弃、**无持久化重试队列**——mg 停机窗口内完成的 turn
不补报(v1 接受丢失,需要可靠送达时停机窗口内先关 `memory.retain.enabled`
免无效重试刷日志)。
## 开发
```bash
source .venv/bin/activate
pytest # 296 个单测(无需任何凭据)
.venv/bin/python scripts/smoke_sdk.py # 真实 runtime 冒烟(需 DEEPSEEK_API_KEY)
.venv/bin/python scripts/smoke_wecom.py # 企微真连冒烟(echo 模式,无需 DEEPSEEK_API_KEY)
.venv/bin/python scripts/smoke_dingtalk.py # 钉钉真连冒烟(echo 模式,无需 DEEPSEEK_API_KEY)
.venv/bin/python scripts/bench_concurrency.py # 并发验证(单 runtime 多 session 并行)
./scripts/smoke_memory.sh # 记忆对接联调冒烟(真机双栈:需本机
# memory-gateway checkout 与引擎栈;产物落 ~/Downloads)
```
能力演进用 [OpenSpec](https://github.com/Fission-AI/OpenSpec) 管理:
`openspec/changes//` 提案 → 实施勾任务 → `openspec archive ` 合并规范。