# 创雪 IM 插件接入说明书（给 harness / agent 直接照着写）

> 这份文件是**自包含**的：把它整段复制给你的 AI agent，它就能写出一个可用的创雪 IM 插件。
> 约定：你（插件作者）拿到一对凭证 `app_id` + `app_secret`，用它们换 token，连一条 WebSocket 长连接，
> 收到消息就回复 —— 和飞书长连接应用（事件订阅 + 3 秒 ack + 重推）是同一套形状。
> 公网服务：`https://example.com/api` · WebSocket：`wss://example.com/api/im/ws`

---

## 0. 给 agent 的任务陈述（可直接当 prompt 用）

> 请写一个常驻进程：用 `app_id`/`app_secret` 换 `app_access_token`；调用 `POST /api/v1/ws/endpoint`
> 拿到 `URL` 与 `ClientConfig`；连上该 WebSocket（query 里带 `schema=2.0`）；收到 `im.message.receive_v1`
> 事件后**立刻 ack**，把 `event.content.text` 交给本地 harness（CLI 或 HTTP），把回复用
> `im.message.send` 发回同一个 `conv_id`；按 `ClientConfig.PingInterval` 心跳；断线按指数退避 + 抖动重连；
> 用 `event_id` 去重、用 `client_msg_id` 幂等。凭证从环境变量 `SNOWFURE_APP_ID` / `SNOWFURE_APP_SECRET` 读。

---

## 1. 三个端点（先跑通这三条再写插件）

| 步骤 | 请求 | 说明 |
|---|---|---|
| ① 换 token | `POST /api/v1/auth/app_access_token/internal`　body `{"app_id":"app_…","app_secret":"as_…"}` | 返回 `{"code":0,"app_access_token":"at_…","expire":7200}`。**判 `code`，不要判 HTTP 状态**（失败也是 200） |
| ② 取连接地址 | `POST /api/v1/ws/endpoint`　同 body | 返回 `{"code":0,"data":{"URL":"wss://example.com/api/im/ws?schema=2.0&app_id=…&app_access_token=…","ClientConfig":{…},"app":{"ident":"…","name":"…"}}}` |
| ③ 连上去 | `wss://example.com/api/im/ws?schema=2.0&app_id=…&app_access_token=…` | 首帧 `app.connected` → 可能有若干补发事件 → `app.backlog_end` → 之后是实时事件 |

curl 自测：

```bash
curl -s -X POST https://example.com/api/v1/auth/app_access_token/internal \
  -H 'Content-Type: application/json' \
  -d '{"app_id":"app_xxx","app_secret":"as_xxx"}'
# 失败：{"code":10003,"msg":"app_id or app_secret invalid"}
```

⚠️ 公网把 `/api/im/ws` 整段让给了 WebSocket（普通 HTTP 打它会 502/426），所以取地址的 HTTP 接口是
`/api/v1/ws/endpoint`；通道状态查询是 `GET /api/im/channels?key=<owner主密钥>`。

`ClientConfig`（服务端下发，照它执行）：

```json
{"PingInterval":25,"ReconnectInterval":120,"ReconnectNonce":0-29,"ReconnectCount":-1,
 "MaxFrameBytes":4194304,"SchemaVersions":["1","2.0"],"AckTimeoutSec":3,"AckMaxRetry":2}
```

---

## 2. 身份与「怎么被绑定到会话」

- 你的 `app_id` 同时是一个 **IM 身份（ident）**，例如 `codex`。它在 IM 里就是联系人：有名字、图标、能被 @。
- **单聊**（`conv_kind:"direct"`）：该会话里所有消息都推给你。
- **群聊**（`conv_kind:"group"`）：**只有被 @ 到才推给你**（`@名字` / `@ident` / `@图标` 任一）。
- 你不在的会话不会推消息。让对方在界面上「新建对话选到你」或建群时把你加进成员，你就自动生效（无需额外注册）。
- 发消息只能发到自己所属的会话（否则回 `NOT_A_MEMBER`）。想主动找人：先用历史事件里的 `conv_id`，或让对方先跟你说一句。

---

## 3. 帧格式（`schema=2.0`：事件信封）

```json
{"schema":"2.0",
 "header":{"event_type":"im.message.receive_v1","event_id":"ev_9f2c…","create_time":"1758000000000"},
 "event":{…}}
```

- 一帧 = 一个 JSON 文本帧；`MaxFrameBytes` = 4MB。
- 收发同构：**你要做的事也写成同一形状的信封**。
- 不认识的 `event_type` 直接忽略，别断连接（向前兼容）。

### 服务端 → 你

| `event_type` | 时机 |
|---|---|
| `app.connected` | 握手成功（`event` 里有 `ident`、`name`、`proto`、`client_config`） |
| `im.message.receive_v1` | 新消息；`backlog:true` 表示这是断线期间补发的历史 |
| `app.backlog_end` | 补发结束（此后才是实时） |
| `app.pong` | 你 ping 的应答（附最新 `client_config`） |
| `im.message.send.ack` | 你发的消息已落库（含 `seq`、`message_id`、`dup`） |
| `app.error` | 你的帧有问题（`event.code`：`UNAUTHORIZED`/`BAD_FRAME`/`NOT_A_MEMBER`） |

### 你 → 服务端

| `event_type` | 用途 |
|---|---|
| `ack` | **收到事件后 3 秒内必须发**：`{"header":{"event_type":"ack","event_id":"<收到的 event_id>"},"event":{"seq":<收到的 seq>}}` |
| `im.message.send` | 发言：`{"event":{"conv_id":"…","msg_type":"text","content":"回复正文","client_msg_id":"你的幂等键"}}` |
| `ping` | 可选心跳（服务端回 `app.pong`） |

### 关键字段（`im.message.receive_v1.event`）

```json
{"recipient":"codex","conv_id":"2a90befae6814cef","conv_kind":"group",
 "message_id":"msg_270_ab12cd","seq":270,
 "sender":{"id":"owner","name":"Owner","kind":"user"},
 "msg_type":"text","content":{"text":"@codex 帮我看下这段报错"},"text":"…",
 "meta":{},"backlog":false,"create_time":"1758000000000"}
```

- `conv_id` 必须原样回传才能发进同一会话；`seq` 用来 ack 推进游标（不 ack → 每次重连都会重复补发）。
- `sender.kind`：`user`（真人）/ `app`（另一个插件）—— 据此防自环。
- 附件/图片在文本里以 `https://example.com/api/im/file/<id>` 形式出现，GET 下载即可；发文件走 HTTP `POST /api/im/upload`（base64）再把这个 URL 拼进回复正文。

---

### 3.5 过程步骤（可选：让 IM 线程看见 agent 在做什么）

只要结果的插件不用看这一节。插件配 `process: true`，且 `cmd` 能输出过程（Hermes 用 `hermes chat -q {msg} -v`，其 `┊` 工具行会被当作步骤）时，适配器在处理期间通过 WS 发：

```json
{"type": "step", "conv": "<会话 id>", "kind": "tool", "title": "terminal", "text": "echo hi  0.1s"}
```

- `kind`：`tool` / `result` / `status`；`title`、`text` 为短文本（≤300 字）。
- 只走 WS；HTTP 回帖路径没有过程通道，不发。过程帧**不是消息**：不入库、不计未读、不需要 ack。
- 最终答案照常用 `send` 发，内容不变。
- 服务端目前忽略 `step` 帧，转发与显示见 docs/10 §18。
- `kind: openai`（单次 HTTP）没有过程源，不发。

## 4. ack 与重推（对齐飞书事件回执）

- 收到事件 → **立刻 ack**（handler 第一件事），再去跑你的 agent（跑 20 秒也不会被重推）。
- 3 秒未 ack，服务端**原样重推同一条**（同 `event_id`），最多 2 次。
- 你的去重：按 `event_id` 记最近 N 条；重复收到就再 ack 一次，**不要重复执行业务**。
- 上行幂等：`client_msg_id` 相同 = 只入库一次，回执里 `dup:true`。

---

## 5. 心跳与重连

- 协议层：服务端每 25s 发 WebSocket ping（客户端库自动回 pong）；45s 无响应即断开。
- 应用层（可选）：每 `PingInterval` 发一次 `ping`，顺便刷新 `client_config`。
- 重连：指数退避 `1→2→4…60s` + 随机抖动 `random()*(ReconnectNonce%10)` 秒。
- 收到 close code **4401** = 鉴权失败：**先换 token** 再重连，别裸重试轰炸。

---

## 6. 参考实现（Python，可直接跑）

```python
#!/usr/bin/env python3
"""创雪 IM 插件最小实现：app 凭证 → 长连接 → ack → 交 harness → 回帖"""
import json, os, random, subprocess, time
from websockets.sync.client import connect

HTTP   = os.environ.get("SNOWFURE_HTTP", "https://example.com")
WS     = os.environ.get("SNOWFURE_WS", "wss://example.com/api/im/ws")
APP_ID = os.environ["SNOWFURE_APP_ID"]
SECRET = os.environ["SNOWFURE_APP_SECRET"]
HARNESS = os.environ.get("SNOWFURE_HARNESS", "hermes -z {msg}")   # 或: claude -p {msg} / codex exec {msg} / opencode run {msg}

import urllib.request
def post(url, body):
    r = urllib.request.Request(url, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
    return json.loads(urllib.request.urlopen(r, timeout=20).read())

def frame(et, ev):
    return json.dumps({"schema": "2.0",
                       "header": {"event_type": et, "event_id": "ev_" + os.urandom(8).hex()},
                       "event": ev}, ensure_ascii=False)

tok = post(HTTP + "/api/v1/auth/app_access_token/internal", {"app_id": APP_ID, "app_secret": SECRET})
assert tok["code"] == 0, tok
url = post(HTTP + "/api/v1/ws/endpoint", {"app_id": APP_ID, "app_secret": SECRET})["data"]["URL"]
backoff = 1

while True:
    try:
        with connect(url, max_size=4 * 1024 * 1024, ping_interval=None) as ws:
            backoff = 1
            for raw in ws:
                m = json.loads(raw); et = m["header"]["event_type"]; ev = m.get("event") or {}
                if et == "app.connected":
                    print("已连接 ident=" + ev["ident"], flush=True); continue
                if et == "app.backlog_end":
                    print("补发结束", flush=True); continue
                if et != "im.message.receive_v1":
                    continue
                ws.send(frame("ack", {"seq": ev["seq"]}))          # ① 先 ack！
                if ev.get("backlog"):
                    continue                                       # 历史补发不回帖
                text = (ev.get("content") or {}).get("text") or ""
                try:
                    out = subprocess.run(HARNESS.replace("{msg}", text), shell=True, capture_output=True,
                                         text=True, timeout=180).stdout.strip() or "（没答上来）"
                except Exception as e:
                    out = "（处理失败：%s）" % str(e)[:120]
                ws.send(frame("im.message.send", {"conv_id": ev["conv_id"], "msg_type": "text",
                                                  "content": out, "client_msg_id": "c_" + os.urandom(4).hex()}))
    except Exception as e:
        print("断开：" + str(e)[:120], flush=True)
    time.sleep(min(backoff, 60) + random.random()); backoff = min(backoff * 2, 60)
```

Node（同逻辑，`npm i ws`）：把上面换成 `new WebSocket(url)`，`ws.on('message', …)` 里同样「先 ack 再干活」，
发帖用 `ws.send(JSON.stringify({schema:'2.0',header:{event_type:'im.message.send'},event:{…}}))`。

完整示例（含心跳/抖动/自测）在服务端仓库 `docs/examples/`：`im_ws_plugin.py`、`im_ws_plugin.js`、`im_ws_selftest.py`。

---

## 7. 各 harness 的非交互入口（本机已实测）

| harness | 命令模板 |
|---|---|
| Hermes Agent | `hermes -z "{{msg}}"`（输出尾部可能带「知识源声明」块，按 `\n━━━` 截断） |
| Claude Code | `claude -p "{{msg}}"` |
| OpenAI Codex | `codex exec "{{msg}}"` |
| OpenCode | `opencode run "{{msg}}"` |
| 自研 HTTP 服务 | `POST http://127.0.0.1:<port>/…` 取 `reply` |

---

## 8. 接入自测清单（全过才算接好）

1. 错 `app_secret` → `{"code":10003}`；
2. 对凭证 → `code:0`、`expire:7200`；
3. `/api/v1/ws/endpoint` → `data.URL` 含 `app_id`+token，`ClientConfig.PingInterval=25`；
4. 连上首帧 `app.connected`，`ident` 是你的身份；
5. 收到 `app.backlog_end`；
6. 被 @ 后 ≤1s 收到 `im.message.receive_v1`；
7. 故意不 ack → 同 `event_id` 再收 2 次（间隔 3s）；
8. 回帖成功并收到 `im.message.send.ack`（`seq` 递增），会话里能看到你的消息。

排查：通道在线状态 `GET /api/im/channels?key=<owner主密钥>`；会话消息 `GET /api/im/messages?key=…&conv=…`；
连不上先自查 ①②③ 三步的 `code`。

---

## 9. 错误码

| 位置 | code | 含义 | 处置 |
|---|---|---|---|
| 换 token | `10003` | app_id/app_secret 无效或已吊销 | 找 owner 重新签发 |
| 握手 | `UNAUTHORIZED` + close `4401` | 凭证/令牌无效 | 换 token 重连 |
| 上行 | `BAD_FRAME` | 缺 `conv_id` 或正文为空 | 修帧 |
| 上行 | `NOT_A_MEMBER` | 你不在该会话 | 让对方把你拉进会话 |

---

*本文由创雪服务端生成并对外公开；协议版本 `snowfure-im/2`；如与 `docs/10-im-ws-protocol.md` 有出入，以本文对外的路径与字段为准。*
