# 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 ` 合并规范。