diff --git a/.gitignore b/.gitignore index 533ec28265b3c1671e8ed0fad9a988656f6189ea..313c4dc446dd35786984a91b3a1ed9e32ad800e7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,9 @@ dist/ cli .codex VersperClaw + +# Desktop (Tauri) build artifacts +desktop/src-tauri/target/ +desktop/src-tauri/binaries/ +desktop/src-tauri/gen/ +desktop/pnpm-lock.yaml diff --git a/adapters/README.md b/adapters/README.md new file mode 100644 index 0000000000000000000000000000000000000000..4f34711adb2853a7b59b08c5891e6142ab4bb16b --- /dev/null +++ b/adapters/README.md @@ -0,0 +1,101 @@ +# Claude Code IM Adapters + +当前目录只放 IM Adapter 运行时代码。 + +用户文档已经迁移到 `docs/`,并且以 Desktop Webapp 配置流程为准: + +- `docs/im/index.md` +- `docs/im/wechat.md` +- `docs/im/dingtalk.md` +- `docs/im/telegram.md` +- `docs/im/feishu.md` + +## 当前方案摘要 + +当前真实链路是: + +```text +Desktop Webapp Settings + -> /api/adapters + -> ~/.claude/adapters.json + -> adapters//index.ts + -> /api/sessions + /ws/:sessionId + -> Claude Code session +``` + +注意两点: + +- IM 配置和配对都在 Desktop Webapp 的 `Settings -> IM 接入` +- Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run wechat`、`bun run dingtalk`、`bun run telegram` 或 `bun run feishu` + +## 快速启动 + +```bash +cd adapters +bun install +bun run telegram +# 或 +bun run feishu +# 或 +bun run wechat +# 或 +bun run dingtalk +``` + +## 开发 + +### 运行测试 + +```bash +cd adapters +bun test +bun test common/ +bun test telegram/ +bun test feishu/ +bun test wechat/ +bun test dingtalk/ +``` + +### 目录结构 + +```text +adapters/ +├── common/ +│ └── attachment/ # 跨平台附件工具(types / limits / store / image-watcher) +├── telegram/ +│ └── media.ts # TelegramMediaService(grammy Bot API 封装) +├── feishu/ +│ ├── media.ts # FeishuMediaService(@larksuiteoapi/node-sdk 封装) +│ └── extract-payload.ts # 入站 im.message.receive_v1 事件解析 +├── wechat/ +│ ├── protocol.ts # 微信 iLink QR 登录 / getupdates / sendmessage 协议封装 +│ └── index.ts # 微信文本聊天 Adapter +├── dingtalk/ +│ ├── helpers.ts # 钉钉 Stream 消息解析与会话键 +│ └── index.ts # 钉钉扫码绑定 / Stream 文本聊天 Adapter +├── package.json +├── tsconfig.json +└── README.md +``` + +## 附件收发 + +两个 Adapter 都支持双向图片/文件,和 Desktop 端走同一套 `AttachmentRef` 协议透传给主进程。 + +**入站(用户 → Claude):** + +- 飞书: 图片(jpg/png/gif/webp/heic)、文档(doc/xls/ppt/pdf 等)、post 富文本里的 img/file 元素 +- Telegram: photo、document、video、audio、voice + +下载落地到 `~/.claude/im-downloads/{platform}/{sessionId}/`,24 小时后自动 GC(`.part` 孤文件 10 分钟超时)。大小限制:单张图 ≤10 MB、单个文件 ≤30 MB,超限直接拒收并在 IM 里提示。 + +**出站(Claude → 用户):** + +Agent 流式文本里的 markdown 图片引用 `![alt](path|url|data:)` 会被 `ImageBlockWatcher` 识别、上传到 IM 平台,作为独立图片消息发出: + +- 飞书: `im.message.create(msg_type='image')` 单发(card 内嵌是后续优化) +- Telegram: `bot.api.sendPhoto(InputFile)` 单发 + +非图片类出站(Agent 产的 pdf/zip 等)暂不支持。 + +设计细节: `docs/superpowers/specs/2026-04-11-im-attachment-support-design.md`。 diff --git a/adapters/bun.lock b/adapters/bun.lock new file mode 100644 index 0000000000000000000000000000000000000000..afad970551890301fc5605a3fc3afd82a557000a --- /dev/null +++ b/adapters/bun.lock @@ -0,0 +1,150 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "claude-code-im-adapters", + "dependencies": { + "@larksuiteoapi/node-sdk": "^1.60.0", + "dingtalk-stream": "2.1.4", + "grammy": "^1.42.0", + "ws": "^8.18.0", + }, + "devDependencies": { + "@types/ws": "^8.5.0", + "bun-types": "latest", + }, + }, + }, + "overrides": { + "follow-redirects": "^1.16.0", + "protobufjs": "^7.5.5", + }, + "packages": { + "@grammyjs/types": ["@grammyjs/types@3.26.0", "https://registry.npmmirror.com/@grammyjs/types/-/types-3.26.0.tgz", {}, "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A=="], + + "@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.60.0", "https://registry.npmmirror.com/@larksuiteoapi/node-sdk/-/node-sdk-1.60.0.tgz", { "dependencies": { "axios": "~1.13.3", "lodash.identity": "^3.0.0", "lodash.merge": "^4.6.2", "lodash.pickby": "^4.6.0", "protobufjs": "^7.2.6", "qs": "^6.14.2", "ws": "^8.19.0" } }, "sha512-MS1eXx7K6HHIyIcCBkJLb21okoa8ZatUGQWZaCCUePm6a37RWFmT6ZKlKvHxAanSX26wNuNlwP0RhgscsE+T6g=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.5.tgz", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "https://registry.npmmirror.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "https://registry.npmmirror.com/@protobufjs/fetch/-/fetch-1.1.0.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "https://registry.npmmirror.com/@protobufjs/float/-/float-1.0.2.tgz", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.1", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.1.tgz", {}, "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "https://registry.npmmirror.com/@protobufjs/path/-/path-1.1.2.tgz", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "https://registry.npmmirror.com/@protobufjs/pool/-/pool-1.1.0.tgz", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.1.tgz", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + + "@types/node": ["@types/node@25.5.2", "https://registry.npmmirror.com/@types/node/-/node-25.5.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "@types/ws": ["@types/ws@8.18.1", "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "abort-controller": ["abort-controller@3.0.0", "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.13.6", "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], + + "bun-types": ["bun-types@1.3.11", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dingtalk-stream": ["dingtalk-stream@2.1.4", "https://registry.npmmirror.com/dingtalk-stream/-/dingtalk-stream-2.1.4.tgz", { "dependencies": { "axios": "^1.4.0", "debug": "^4.3.4", "ws": "^8.13.0" } }, "sha512-rgQbXLGWfASuB9onFcqXTnRSj4ZotimhBOnzrB4kS19AaU9lshXiuofs1GAYcKh5uzPWCAuEs3tMtiadTQWP4A=="], + + "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "event-target-shim": ["event-target-shim@5.0.1", "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "follow-redirects": ["follow-redirects@1.16.0", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + + "form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "grammy": ["grammy@1.42.0", "https://registry.npmmirror.com/grammy/-/grammy-1.42.0.tgz", { "dependencies": { "@grammyjs/types": "3.26.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g=="], + + "has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "lodash.identity": ["lodash.identity@3.0.0", "https://registry.npmmirror.com/lodash.identity/-/lodash.identity-3.0.0.tgz", {}, "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q=="], + + "lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "lodash.pickby": ["lodash.pickby@4.6.0", "https://registry.npmmirror.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz", {}, "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q=="], + + "long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-fetch": ["node-fetch@2.7.0", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "protobufjs": ["protobufjs@7.5.6", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.6.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "qs": ["qs@6.15.0", "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "tr46": ["tr46@0.0.3", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "undici-types": ["undici-types@7.18.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "ws": ["ws@8.20.0", "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "@protobufjs/fetch/@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.0.tgz", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + } +} diff --git a/adapters/common/__tests__/chat-queue.test.ts b/adapters/common/__tests__/chat-queue.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..36c8c9c246d98b656581028425f331793f552d2b --- /dev/null +++ b/adapters/common/__tests__/chat-queue.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'bun:test' +import { enqueue } from '../chat-queue.js' + +describe('ChatQueue', () => { + it('executes tasks for the same chatId serially', async () => { + const order: number[] = [] + + await Promise.all([ + enqueue('chat-1', async () => { + await new Promise((r) => setTimeout(r, 30)) + order.push(1) + }), + enqueue('chat-1', async () => { + order.push(2) + }), + enqueue('chat-1', async () => { + order.push(3) + }), + ]) + + // Wait for all to complete + await new Promise((r) => setTimeout(r, 50)) + expect(order).toEqual([1, 2, 3]) + }) + + it('executes tasks for different chatIds in parallel', async () => { + const order: string[] = [] + + const p1 = enqueue('chat-a', async () => { + await new Promise((r) => setTimeout(r, 30)) + order.push('a') + }) + + const p2 = enqueue('chat-b', async () => { + order.push('b') // should run immediately, not wait for chat-a + }) + + await Promise.all([p1, p2]) + await new Promise((r) => setTimeout(r, 50)) + + // 'b' should appear before 'a' since chat-a has a delay + expect(order[0]).toBe('b') + expect(order[1]).toBe('a') + }) + + it('continues processing after a task fails', async () => { + const order: number[] = [] + + await enqueue('chat-err', async () => { + order.push(1) + throw new Error('task failed') + }) + + await enqueue('chat-err', async () => { + order.push(2) // should still run + }) + + await new Promise((r) => setTimeout(r, 20)) + expect(order).toEqual([1, 2]) + }) +}) diff --git a/adapters/common/__tests__/config.test.ts b/adapters/common/__tests__/config.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6fa34847306b734232ec0de923c1ae74a938ba5a --- /dev/null +++ b/adapters/common/__tests__/config.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { getConfiguredWorkDir, loadConfig } from '../config.js' + +describe('adapter config defaults', () => { + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR + const originalAdapterDefaultWorkDir = process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR + const originalAdapterDefaultProjectDir = process.env.ADAPTER_DEFAULT_PROJECT_DIR + const originalDingtalkPermissionCardTemplateId = process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID + const originalPwd = process.env.PWD + + afterEach(() => { + restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir) + restoreEnv('CLAUDE_ADAPTER_DEFAULT_WORK_DIR', originalAdapterDefaultWorkDir) + restoreEnv('ADAPTER_DEFAULT_PROJECT_DIR', originalAdapterDefaultProjectDir) + restoreEnv('DINGTALK_PERMISSION_CARD_TEMPLATE_ID', originalDingtalkPermissionCardTemplateId) + restoreEnv('PWD', originalPwd) + }) + + it('uses the user shell working directory when no default project is configured', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-')) + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-workdir-')) + try { + process.env.CLAUDE_CONFIG_DIR = configDir + delete process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR + process.env.PWD = workDir + + const config = loadConfig() + + expect(config.telegram.defaultWorkDir).toBe(fs.realpathSync(workDir)) + expect(config.feishu.defaultWorkDir).toBe(fs.realpathSync(workDir)) + expect(config.wechat.defaultWorkDir).toBe(fs.realpathSync(workDir)) + expect(config.dingtalk.defaultWorkDir).toBe(fs.realpathSync(workDir)) + expect(getConfiguredWorkDir(config, config.wechat)).toBe(fs.realpathSync(workDir)) + expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(fs.realpathSync(workDir)) + } finally { + fs.rmSync(configDir, { recursive: true, force: true }) + fs.rmSync(workDir, { recursive: true, force: true }) + } + }) + + it('keeps the explicit default project ahead of the platform default work dir', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-')) + const defaultProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-project-')) + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-workdir-')) + try { + fs.writeFileSync( + path.join(configDir, 'adapters.json'), + JSON.stringify({ defaultProjectDir }), + ) + process.env.CLAUDE_CONFIG_DIR = configDir + process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR = workDir + + const config = loadConfig() + + expect(getConfiguredWorkDir(config, config.wechat)).toBe(defaultProjectDir) + expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(defaultProjectDir) + expect(config.wechat.defaultWorkDir).toBe(fs.realpathSync(workDir)) + expect(config.dingtalk.defaultWorkDir).toBe(fs.realpathSync(workDir)) + } finally { + fs.rmSync(configDir, { recursive: true, force: true }) + fs.rmSync(defaultProjectDir, { recursive: true, force: true }) + fs.rmSync(workDir, { recursive: true, force: true }) + } + }) + + it('accepts ADAPTER_DEFAULT_PROJECT_DIR as a sidecar-friendly default work dir override', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-')) + const defaultProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-project-')) + try { + process.env.CLAUDE_CONFIG_DIR = configDir + process.env.ADAPTER_DEFAULT_PROJECT_DIR = defaultProjectDir + delete process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR + delete process.env.PWD + + const config = loadConfig() + + expect(getConfiguredWorkDir(config, config.wechat)).toBe(fs.realpathSync(defaultProjectDir)) + expect(getConfiguredWorkDir(config, config.dingtalk)).toBe(fs.realpathSync(defaultProjectDir)) + } finally { + fs.rmSync(configDir, { recursive: true, force: true }) + fs.rmSync(defaultProjectDir, { recursive: true, force: true }) + } + }) + + it('loads DingTalk permission card template id from file or env', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-')) + try { + fs.writeFileSync( + path.join(configDir, 'adapters.json'), + JSON.stringify({ dingtalk: { permissionCardTemplateId: 'file-template' } }), + ) + process.env.CLAUDE_CONFIG_DIR = configDir + delete process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID + expect(loadConfig().dingtalk.permissionCardTemplateId).toBe('file-template') + + process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID = 'env-template' + expect(loadConfig().dingtalk.permissionCardTemplateId).toBe('env-template') + } finally { + fs.rmSync(configDir, { recursive: true, force: true }) + } + }) +}) + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } +} diff --git a/adapters/common/__tests__/format.test.ts b/adapters/common/__tests__/format.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0597516f9b563f995cc19aacf5a8d983785383f8 --- /dev/null +++ b/adapters/common/__tests__/format.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect } from 'bun:test' +import { + convertMarkdownTablesToBullets, + formatImHelp, + formatImStatus, + splitMessage, + formatToolUse, + formatPermissionRequest, + truncateInput, + escapeMarkdownV2, +} from '../format.js' + +describe('splitMessage', () => { + it('returns single chunk for short text', () => { + expect(splitMessage('hello', 100)).toEqual(['hello']) + }) + + it('splits at paragraph boundary', () => { + const text = 'First paragraph.\n\nSecond paragraph.' + const chunks = splitMessage(text, 20) + expect(chunks.length).toBeGreaterThan(1) + expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('First paragraph') + expect(chunks.join(' ').replace(/\s+/g, ' ')).toContain('Second paragraph') + }) + + it('splits at newline if no paragraph break', () => { + const text = 'Line one\nLine two\nLine three\nLine four' + const chunks = splitMessage(text, 20) + expect(chunks.length).toBeGreaterThan(1) + }) + + it('hard-splits at limit if no natural break', () => { + const text = 'a'.repeat(50) + const chunks = splitMessage(text, 20) + expect(chunks.length).toBe(3) // 20 + 20 + 10 + expect(chunks.every((c) => c.length <= 20)).toBe(true) + }) + + it('preserves all content after splitting', () => { + const text = 'Hello world. This is a test. Foo bar baz.' + const chunks = splitMessage(text, 15) + const joined = chunks.join(' ') + // All words should be present + expect(joined).toContain('Hello') + expect(joined).toContain('test') + expect(joined).toContain('baz') + }) +}) + +describe('convertMarkdownTablesToBullets', () => { + it('converts pipe tables into row-labeled bullets', () => { + const markdown = [ + 'Before', + '', + '| Feature | Status | Notes |', + '| --- | --- | --- |', + '| Auth | Done | OAuth2 |', + '| API | WIP | REST only |', + '', + 'After', + ].join('\n') + + expect(convertMarkdownTablesToBullets(markdown)).toBe([ + 'Before', + '', + 'Auth', + '• Status: Done', + '• Notes: OAuth2', + '', + 'API', + '• Status: WIP', + '• Notes: REST only', + '', + 'After', + ].join('\n')) + }) + + it('skips empty table cells', () => { + const markdown = [ + '| Item | Value | Notes |', + '| --- | --- | --- |', + '| One | 1 | |', + ].join('\n') + + expect(convertMarkdownTablesToBullets(markdown)).toBe([ + 'One', + '• Value: 1', + ].join('\n')) + }) + + it('leaves non-table pipe text unchanged', () => { + const markdown = 'Use foo | bar as plain text.' + expect(convertMarkdownTablesToBullets(markdown)).toBe(markdown) + }) + + it('does not rewrite pipe tables inside fenced code blocks', () => { + const markdown = [ + '```', + '| Feature | Status |', + '| --- | --- |', + '| Auth | Done |', + '```', + ].join('\n') + + expect(convertMarkdownTablesToBullets(markdown)).toBe(markdown) + }) +}) + +describe('formatToolUse', () => { + it('includes tool name and input preview', () => { + const result = formatToolUse('Bash', { command: 'npm test' }) + expect(result).toContain('🔧 Bash') + expect(result).toContain('npm test') + }) +}) + +describe('formatPermissionRequest', () => { + it('includes tool name, input preview, and request ID', () => { + const result = formatPermissionRequest('Bash', { command: 'rm -rf /' }, 'abcde') + expect(result).toContain('🔐') + expect(result).toContain('Bash') + expect(result).toContain('abcde') + expect(result).toContain('rm -rf') + }) +}) + +describe('truncateInput', () => { + it('returns short input as-is', () => { + expect(truncateInput('hello', 100)).toBe('hello') + }) + + it('truncates long input with ellipsis', () => { + const long = 'x'.repeat(300) + const result = truncateInput(long, 100) + expect(result.length).toBe(101) // 100 chars + '…' + expect(result.endsWith('…')).toBe(true) + }) + + it('handles objects by stringifying', () => { + const result = truncateInput({ key: 'value' }, 100) + expect(result).toContain('key') + expect(result).toContain('value') + }) + + it('handles unserializable input', () => { + const circular: any = {} + circular.self = circular + expect(truncateInput(circular, 100)).toBe('(unserializable)') + }) +}) + +describe('escapeMarkdownV2', () => { + it('escapes special characters', () => { + expect(escapeMarkdownV2('hello_world')).toBe('hello\\_world') + expect(escapeMarkdownV2('a*b*c')).toBe('a\\*b\\*c') + expect(escapeMarkdownV2('test.md')).toBe('test\\.md') + }) + + it('leaves plain text unchanged', () => { + expect(escapeMarkdownV2('hello world')).toBe('hello world') + }) +}) + +describe('formatImHelp', () => { + it('lists the lightweight IM commands', () => { + const text = formatImHelp() + expect(text).toContain('/new') + expect(text).toContain('/projects') + expect(text).toContain('/status') + expect(text).toContain('/clear') + expect(text).toContain('/stop') + expect(text).toContain('/help') + expect(text).toContain('项目列表') + expect(text).toContain('/allow ') + }) +}) + +describe('formatImStatus', () => { + it('formats an active session summary for mobile reading', () => { + const text = formatImStatus({ + sessionId: 'abc1234567890', + projectName: 'claude-code-haha', + branch: 'main', + model: 'claude-sonnet', + state: 'tool_executing', + verb: 'Running tests', + pendingPermissionCount: 1, + taskCounts: { + total: 4, + pending: 1, + inProgress: 2, + completed: 1, + }, + }) + + expect(text).toContain('项目: claude-code-haha (main)') + expect(text).toContain('会话: abc12345…') + expect(text).toContain('模型: claude-sonnet') + expect(text).toContain('状态: 执行工具中 (Running tests)') + expect(text).toContain('审批: 1 个待确认') + expect(text).toContain('任务: 总计 4 · 进行中 2 · 待处理 1 · 已完成 1') + }) + + it('returns a friendly empty-session message when nothing is active', () => { + const text = formatImStatus(null) + expect(text).toContain('当前没有活动会话') + expect(text).toContain('/new') + expect(text).toContain('/projects') + }) +}) diff --git a/adapters/common/__tests__/http-client.test.ts b/adapters/common/__tests__/http-client.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4c83622f127cf5fc0129cd33ff01f4179ad7005 --- /dev/null +++ b/adapters/common/__tests__/http-client.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { AdapterHttpClient } from '../http-client.js' + +describe('AdapterHttpClient', () => { + let client: AdapterHttpClient + const originalFetch = globalThis.fetch + + beforeEach(() => { + client = new AdapterHttpClient('ws://127.0.0.1:3456') + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('derives HTTP URL from WS URL', () => { + expect(client.httpBaseUrl).toBe('http://127.0.0.1:3456') + + const secure = new AdapterHttpClient('wss://example.com:443') + expect(secure.httpBaseUrl).toBe('https://example.com:443') + }) + + it('createSession calls POST /api/sessions', async () => { + const mockSessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ sessionId: mockSessionId }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + })) + ) as any + + const sessionId = await client.createSession('/path/to/project') + expect(sessionId).toBe(mockSessionId) + + const call = (globalThis.fetch as any).mock.calls[0] + expect(call[0]).toBe('http://127.0.0.1:3456/api/sessions') + const body = JSON.parse(call[1].body) + expect(body.workDir).toBe('/path/to/project') + }) + + it('listRecentProjects calls GET /api/sessions/recent-projects', async () => { + const mockProjects = [ + { projectName: 'my-app', realPath: '/home/user/my-app', sessionCount: 3 }, + ] + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ projects: mockProjects }), { + headers: { 'Content-Type': 'application/json' }, + })) + ) as any + + const projects = await client.listRecentProjects() + expect(projects).toHaveLength(1) + expect(projects[0].projectName).toBe('my-app') + }) + + it('matchProject accepts an absolute local project path inside an allowed root without recent history', async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-')) + const projectDir = fs.mkdtempSync(path.join(rootDir, 'project-')) + try { + client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] }) + globalThis.fetch = mock(() => { + throw new Error('recent projects should not be queried for absolute paths') + }) as any + + const result = await client.matchProject(projectDir) + + expect(result.project?.realPath).toBe(fs.realpathSync(projectDir)) + expect(result.project?.projectName).toBe(path.basename(projectDir)) + expect((globalThis.fetch as any).mock.calls).toHaveLength(0) + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }) + } + }) + + it('matchProject rejects absolute local project paths outside allowed roots', async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-')) + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-')) + try { + client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] }) + globalThis.fetch = mock(() => { + throw new Error('recent projects should not be queried for rejected absolute paths') + }) as any + + const result = await client.matchProject(projectDir) + + expect(result.project).toBeUndefined() + expect(result.ambiguous).toBeUndefined() + expect((globalThis.fetch as any).mock.calls).toHaveLength(0) + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }) + fs.rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('createSession throws on server error', async () => { + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ error: 'BAD_REQUEST', message: 'workDir required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + })) + ) as any + + expect(client.createSession('')).rejects.toThrow() + }) + + it('getGitInfo calls GET /api/sessions/:id/git-info', async () => { + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ + branch: 'main', + repoName: 'claude-code-haha', + workDir: '/repo/claude-code-haha', + changedFiles: 2, + }), { + headers: { 'Content-Type': 'application/json' }, + })) + ) as any + + const gitInfo = await client.getGitInfo('session-123') + expect(gitInfo.repoName).toBe('claude-code-haha') + expect((globalThis.fetch as any).mock.calls[0][0]).toBe( + 'http://127.0.0.1:3456/api/sessions/session-123/git-info', + ) + }) + + it('getTasksForSession calls GET /api/tasks/lists/:id', async () => { + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ + tasks: [ + { id: '1', subject: 'Fix bug', status: 'in_progress' }, + { id: '2', subject: 'Write docs', status: 'pending' }, + ], + }), { + headers: { 'Content-Type': 'application/json' }, + })) + ) as any + + const tasks = await client.getTasksForSession('session-123') + expect(tasks).toHaveLength(2) + expect(tasks[0]?.status).toBe('in_progress') + expect((globalThis.fetch as any).mock.calls[0][0]).toBe( + 'http://127.0.0.1:3456/api/tasks/lists/session-123', + ) + }) +}) diff --git a/adapters/common/__tests__/message-buffer.test.ts b/adapters/common/__tests__/message-buffer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e8a037b2009eb7732f016b9a446993fe658fbc5 --- /dev/null +++ b/adapters/common/__tests__/message-buffer.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach } from 'bun:test' +import { MessageBuffer } from '../message-buffer.js' + +describe('MessageBuffer', () => { + it('accumulates text and flushes on complete', async () => { + const flushed: Array<{ text: string; isComplete: boolean }> = [] + const buf = new MessageBuffer( + (text, isComplete) => { flushed.push({ text, isComplete }) }, + 500, // 500ms interval + 1000, // 1000 char threshold + ) + + buf.append('Hello ') + buf.append('World') + await buf.complete() + + expect(flushed.length).toBeGreaterThanOrEqual(1) + const allText = flushed.map((f) => f.text).join('') + expect(allText).toBe('Hello World') + // Last flush should be marked complete + expect(flushed[flushed.length - 1]!.isComplete).toBe(true) + }) + + it('flushes when character threshold is reached', async () => { + const flushed: string[] = [] + const buf = new MessageBuffer( + (text) => { flushed.push(text) }, + 10000, // very long interval (won't trigger) + 10, // 10 char threshold + ) + + buf.append('12345678901') // 11 chars > threshold + + // Wait for microtask + await new Promise((r) => setTimeout(r, 10)) + expect(flushed.length).toBeGreaterThanOrEqual(1) + + buf.reset() + }) + + it('flushes on timer interval', async () => { + const flushed: string[] = [] + const buf = new MessageBuffer( + (text) => { flushed.push(text) }, + 50, // 50ms interval + 1000, + ) + + buf.append('hi') + + // Wait for timer + await new Promise((r) => setTimeout(r, 80)) + expect(flushed).toContain('hi') + + buf.reset() + }) + + it('does not flush empty buffer on complete', async () => { + const flushed: string[] = [] + const buf = new MessageBuffer( + (text) => { flushed.push(text) }, + ) + + await buf.complete() + expect(flushed.length).toBe(0) + }) + + it('waits for an in-flight flush before complete resolves', async () => { + let releaseFlush!: () => void + let flushStarted = false + const flushed: Array<{ text: string; isComplete: boolean }> = [] + const buf = new MessageBuffer( + async (text, isComplete) => { + flushStarted = true + flushed.push({ text, isComplete }) + await new Promise((resolve) => { + releaseFlush = resolve + }) + }, + 10000, + 3, + ) + + buf.append('abcd') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(flushStarted).toBe(true) + + let completeResolved = false + const completing = buf.complete().then(() => { + completeResolved = true + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(completeResolved).toBe(false) + + releaseFlush() + await completing + expect(completeResolved).toBe(true) + expect(flushed).toEqual([{ text: 'abcd', isComplete: false }]) + }) + + it('resets properly between messages', async () => { + const flushed: string[] = [] + const buf = new MessageBuffer( + (text) => { flushed.push(text) }, + 500, + 1000, + ) + + buf.append('first') + buf.reset() + buf.append('second') + await buf.complete() + + const allText = flushed.map((f) => f).join('') + expect(allText).toBe('second') + }) +}) diff --git a/adapters/common/__tests__/message-dedup.test.ts b/adapters/common/__tests__/message-dedup.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..68f41fb47b48320cfb1575e5a33fee83b36966c2 --- /dev/null +++ b/adapters/common/__tests__/message-dedup.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { MessageDedup } from '../message-dedup.js' + +describe('MessageDedup', () => { + let dedup: MessageDedup + + beforeEach(() => { + dedup = new MessageDedup(1000, 100) // 1s TTL, 100 max entries + }) + + afterEach(() => { + dedup.destroy() + }) + + it('returns true for new messages', () => { + expect(dedup.tryRecord('msg-1')).toBe(true) + expect(dedup.tryRecord('msg-2')).toBe(true) + }) + + it('returns false for duplicate messages', () => { + expect(dedup.tryRecord('msg-1')).toBe(true) + expect(dedup.tryRecord('msg-1')).toBe(false) + expect(dedup.tryRecord('msg-1')).toBe(false) + }) + + it('allows same ID after TTL expires', async () => { + const shortDedup = new MessageDedup(50, 100) // 50ms TTL + expect(shortDedup.tryRecord('msg-1')).toBe(true) + expect(shortDedup.tryRecord('msg-1')).toBe(false) + await new Promise((r) => setTimeout(r, 60)) + expect(shortDedup.tryRecord('msg-1')).toBe(true) + shortDedup.destroy() + }) + + it('evicts oldest entry when at capacity', () => { + const smallDedup = new MessageDedup(60_000, 3) // max 3 entries + expect(smallDedup.tryRecord('a')).toBe(true) + expect(smallDedup.tryRecord('b')).toBe(true) + expect(smallDedup.tryRecord('c')).toBe(true) + // Adding 4th should evict 'a' + expect(smallDedup.tryRecord('d')).toBe(true) + // 'a' was evicted, should be treated as new + expect(smallDedup.tryRecord('a')).toBe(true) + // Now store has {c, d, a} — 'b' was evicted when 'a' was re-inserted + // 'c' should still be deduped (was not evicted) + expect(smallDedup.tryRecord('c')).toBe(false) + smallDedup.destroy() + }) + + it('handles distinct messages independently', () => { + expect(dedup.tryRecord('msg-1')).toBe(true) + expect(dedup.tryRecord('msg-2')).toBe(true) + expect(dedup.tryRecord('msg-1')).toBe(false) + expect(dedup.tryRecord('msg-2')).toBe(false) + expect(dedup.tryRecord('msg-3')).toBe(true) + }) +}) diff --git a/adapters/common/__tests__/pairing.test.ts b/adapters/common/__tests__/pairing.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f45547885eaa53b3aea5794b71b515982225e95 --- /dev/null +++ b/adapters/common/__tests__/pairing.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'bun:test' +import { isPaired } from '../pairing.js' + +describe('pairing platform support', () => { + it('checks DingTalk paired users with the same shared access rule', () => { + expect(isPaired('dingtalk', 'staff-1', { + dingtalk: { + pairedUsers: [{ userId: 'staff-1', displayName: 'DingTalk User', pairedAt: Date.now() }], + allowedUsers: [], + }, + })).toBe(true) + }) + + it('keeps empty DingTalk allow and pair lists closed by default', () => { + expect(isPaired('dingtalk', 'staff-1', { + dingtalk: { + pairedUsers: [], + allowedUsers: [], + }, + })).toBe(false) + }) +}) diff --git a/adapters/common/__tests__/permission.test.ts b/adapters/common/__tests__/permission.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6fa01885a1cc1dbf60ee95de030d7f8973747cfe --- /dev/null +++ b/adapters/common/__tests__/permission.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'bun:test' +import { + formatPermissionDecisionStatus, + formatPermissionInstructions, + parsePermissionCommand, + parsePermitCallbackData, +} from '../permission.js' + +describe('permission helpers', () => { + it('parses text permission commands', () => { + expect(parsePermissionCommand('/allow req-1')).toEqual({ requestId: 'req-1', allowed: true }) + expect(parsePermissionCommand('/always req-2')).toEqual({ requestId: 'req-2', allowed: true, rule: 'always' }) + expect(parsePermissionCommand('/allow-always req-3')).toEqual({ requestId: 'req-3', allowed: true, rule: 'always' }) + expect(parsePermissionCommand('/deny req-4')).toEqual({ requestId: 'req-4', allowed: false }) + }) + + it('parses short replies when one permission is pending', () => { + const pending = new Set(['req-1']) + expect(parsePermissionCommand('1', pending)).toEqual({ requestId: 'req-1', allowed: true }) + expect(parsePermissionCommand('2', pending)).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' }) + expect(parsePermissionCommand('3', pending)).toEqual({ requestId: 'req-1', allowed: false }) + expect(parsePermissionCommand('/always', pending)).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' }) + expect(parsePermissionCommand('永久允许', pending)).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' }) + }) + + it('does not parse short replies when multiple permissions are pending', () => { + expect(parsePermissionCommand('1', new Set(['req-1', 'req-2']))).toBeNull() + }) + + it('parses callback permission actions', () => { + expect(parsePermitCallbackData('permit:req-1:yes')).toEqual({ requestId: 'req-1', allowed: true }) + expect(parsePermitCallbackData('permit:req-2:always')).toEqual({ requestId: 'req-2', allowed: true, rule: 'always' }) + expect(parsePermitCallbackData('permit:req-3:no')).toEqual({ requestId: 'req-3', allowed: false }) + expect(parsePermitCallbackData('permit:req-4:unknown')).toBeNull() + }) + + it('formats text fallback and status labels', () => { + expect(formatPermissionInstructions('req-1')).toContain('回复 1') + expect(formatPermissionInstructions('req-1')).toContain('/always req-1') + expect(formatPermissionDecisionStatus({ allowed: true, rule: 'always' })).toContain('永久允许') + expect(formatPermissionDecisionStatus({ allowed: false })).toContain('拒绝') + }) +}) diff --git a/adapters/common/__tests__/session-store.test.ts b/adapters/common/__tests__/session-store.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a029084ee2971970cc6020cb2c6707362f133b86 --- /dev/null +++ b/adapters/common/__tests__/session-store.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import { SessionStore } from '../session-store.js' + +describe('SessionStore', () => { + let tmpDir: string + let store: SessionStore + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-store-')) + store = new SessionStore(path.join(tmpDir, 'sessions.json')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('returns null for unknown chatId', () => { + expect(store.get('unknown')).toBeNull() + }) + + it('stores and retrieves a session', () => { + store.set('chat-1', 'uuid-aaa', '/path/to/project') + const entry = store.get('chat-1') + expect(entry).not.toBeNull() + expect(entry!.sessionId).toBe('uuid-aaa') + expect(entry!.workDir).toBe('/path/to/project') + }) + + it('overwrites existing entry on set', () => { + store.set('chat-1', 'uuid-aaa', '/old') + store.set('chat-1', 'uuid-bbb', '/new') + expect(store.get('chat-1')!.sessionId).toBe('uuid-bbb') + }) + + it('deletes an entry', () => { + store.set('chat-1', 'uuid-aaa', '/path') + store.delete('chat-1') + expect(store.get('chat-1')).toBeNull() + }) + + it('deletes every chat entry bound to a sessionId', () => { + store.set('chat-1', 'uuid-shared', '/project-a') + store.set('chat-2', 'uuid-other', '/project-b') + store.set('chat-3', 'uuid-shared', '/project-c') + + const removed = store.deleteBySessionId('uuid-shared') + + expect(removed.sort()).toEqual(['chat-1', 'chat-3']) + expect(store.get('chat-1')).toBeNull() + expect(store.get('chat-3')).toBeNull() + expect(store.get('chat-2')!.sessionId).toBe('uuid-other') + + const reloaded = new SessionStore(path.join(tmpDir, 'sessions.json')) + expect(reloaded.get('chat-1')).toBeNull() + expect(reloaded.get('chat-3')).toBeNull() + expect(reloaded.get('chat-2')!.sessionId).toBe('uuid-other') + }) + + it('refreshes from disk before reading so running adapters do not reuse deleted mappings', () => { + store.set('chat-1', 'uuid-stale', '/project') + const serverSideStore = new SessionStore(path.join(tmpDir, 'sessions.json')) + + expect(serverSideStore.deleteBySessionId('uuid-stale')).toEqual(['chat-1']) + + expect(store.get('chat-1')).toBeNull() + expect(store.listAll()).toEqual([]) + }) + + it('returns an empty list when deleting an unknown sessionId', () => { + store.set('chat-1', 'uuid-aaa', '/project') + + expect(store.deleteBySessionId('uuid-missing')).toEqual([]) + expect(store.get('chat-1')!.sessionId).toBe('uuid-aaa') + }) + + it('persists to disk and reloads', () => { + store.set('chat-1', 'uuid-aaa', '/path') + + const store2 = new SessionStore(path.join(tmpDir, 'sessions.json')) + expect(store2.get('chat-1')!.sessionId).toBe('uuid-aaa') + }) + + it('handles missing file gracefully', () => { + const store2 = new SessionStore(path.join(tmpDir, 'nonexistent.json')) + expect(store2.get('anything')).toBeNull() + }) + + it('lists all entries', () => { + store.set('chat-1', 'uuid-1', '/a') + store.set('chat-2', 'uuid-2', '/b') + const all = store.listAll() + expect(all).toHaveLength(2) + }) +}) diff --git a/adapters/common/__tests__/ws-bridge.test.ts b/adapters/common/__tests__/ws-bridge.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e8618b21a25f1a778b2410642ef858761c829fc --- /dev/null +++ b/adapters/common/__tests__/ws-bridge.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { WsBridge } from '../ws-bridge.js' +import { WebSocketServer, type WebSocket as WsServerSocket } from 'ws' + +describe('WsBridge', () => { + let bridge: WsBridge + + beforeEach(() => { + bridge = new WsBridge('ws://127.0.0.1:19999', 'test') + }) + + afterEach(() => { + bridge.destroy() + }) + + it('connectSession connects with provided sessionId', () => { + const result = bridge.connectSession('chat-1', 'my-uuid-session-id') + expect(result).toBe(true) + expect(bridge.hasSession('chat-1')).toBe(true) + }) + + it('connectSession for different chatIds creates separate sessions', () => { + bridge.connectSession('chat-1', 'uuid-1') + bridge.connectSession('chat-2', 'uuid-2') + expect(bridge.hasSession('chat-1')).toBe(true) + expect(bridge.hasSession('chat-2')).toBe(true) + }) + + it('resetSession removes the session', () => { + bridge.connectSession('chat-reset', 'uuid-reset') + bridge.resetSession('chat-reset') + expect(bridge.hasSession('chat-reset')).toBe(false) + }) + + it('sendUserMessage returns false when no open connection', () => { + bridge.connectSession('chat-offline', 'uuid-offline') + expect(bridge.sendUserMessage('chat-offline', 'hello')).toBe(false) + }) + + it('sendPermissionResponse returns false when no open connection', () => { + bridge.connectSession('chat-perm', 'uuid-perm') + expect(bridge.sendPermissionResponse('chat-perm', 'req-1', true)).toBe(false) + }) + + it('sendStopGeneration returns false when no open connection', () => { + bridge.connectSession('chat-stop', 'uuid-stop') + expect(bridge.sendStopGeneration('chat-stop')).toBe(false) + }) + + it('destroy cleans up all sessions', () => { + bridge.connectSession('a', 'uuid-a') + bridge.connectSession('b', 'uuid-b') + bridge.destroy() + expect(bridge.hasSession('a')).toBe(false) + expect(bridge.hasSession('b')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Integration: per-chat handler serialization +// +// Reproduces the feishu text→tool→text race: a slow handler on msg 1 must +// complete BEFORE msg 2's handler starts, otherwise msg 2 reads the stale +// state msg 1's continuation is about to clear. +// --------------------------------------------------------------------------- + +describe('WsBridge: handler serialization', () => { + let server: WebSocketServer + let port: number + let connections: WsServerSocket[] + let serverUrl: string + + beforeEach(async () => { + connections = [] + // port 0 → let the OS pick a free one + server = new WebSocketServer({ port: 0 }) + server.on('connection', (ws) => { + connections.push(ws) + }) + await new Promise((resolve) => server.on('listening', () => resolve())) + port = (server.address() as { port: number }).port + serverUrl = `ws://127.0.0.1:${port}` + }) + + afterEach(async () => { + // Forcibly kill any server-side sockets (not graceful close) so + // WebSocketServer.close() doesn't wait for client FIN. + for (const ws of connections) { + try { ws.terminate() } catch {} + } + await new Promise((resolve) => { + const t = setTimeout(() => resolve(), 500) // hard cap + server.close(() => { + clearTimeout(t) + resolve() + }) + }) + }) + + async function waitForServerConnection(): Promise { + if (connections[0]) return connections[0] + await new Promise((resolve, reject) => { + const onConnection = () => { + clearTimeout(timer) + resolve() + } + const timer = setTimeout(() => { + server.off('connection', onConnection) + reject(new Error('Timed out waiting for test WebSocket connection')) + }, 500) + server.once('connection', onConnection) + }) + return connections[0]! + } + + it('processes handler calls in strict FIFO order per chatId', async () => { + const bridge = new WsBridge(serverUrl, 'test') + const events: string[] = [] + + // The handler simulates an async side effect that takes varying time. + // If handlers ran concurrently, fast msgs could finish before slow ones, + // producing an out-of-order `events` array. + bridge.onServerMessage('chat-1', async (msg: any) => { + const tag = msg.tag as string + const delay = msg.delay as number + events.push(`start:${tag}`) + await new Promise((r) => setTimeout(r, delay)) + events.push(`end:${tag}`) + }) + + bridge.connectSession('chat-1', 'sess-1') + const ok = await bridge.waitForOpen('chat-1') + expect(ok).toBe(true) + const serverWs = await waitForServerConnection() + + // Blast three messages back-to-back. msg1 is slow, msg2/msg3 are fast. + // With serialization: start:1, end:1, start:2, end:2, start:3, end:3 + // Without serialization: start:1, start:2, start:3, end:2, end:3, end:1 + serverWs.send(JSON.stringify({ tag: '1', delay: 40 })) + serverWs.send(JSON.stringify({ tag: '2', delay: 5 })) + serverWs.send(JSON.stringify({ tag: '3', delay: 5 })) + + // Wait long enough for all three handlers to run serially + await new Promise((r) => setTimeout(r, 200)) + + expect(events).toEqual([ + 'start:1', 'end:1', + 'start:2', 'end:2', + 'start:3', 'end:3', + ]) + + bridge.destroy() + }) + + it('handler error does not break the chain (subsequent messages still run)', async () => { + const bridge = new WsBridge(serverUrl, 'test') + const events: string[] = [] + + bridge.onServerMessage('chat-err', async (msg: any) => { + if (msg.throw) { + events.push('throwing') + throw new Error('boom') + } + events.push(`ok:${msg.tag}`) + }) + + bridge.connectSession('chat-err', 'sess-err') + await bridge.waitForOpen('chat-err') + const serverWs = await waitForServerConnection() + + serverWs.send(JSON.stringify({ throw: true })) + serverWs.send(JSON.stringify({ tag: 'after' })) + + await new Promise((r) => setTimeout(r, 80)) + + expect(events).toEqual(['throwing', 'ok:after']) + + bridge.destroy() + }) + + it('forgets a chat when the server closes the session normally', async () => { + const bridge = new WsBridge(serverUrl, 'test') + bridge.onServerMessage('chat-deleted', () => {}) + bridge.connectSession('chat-deleted', 'sess-deleted') + await bridge.waitForOpen('chat-deleted') + + const serverWs = await waitForServerConnection() + serverWs.close(1000, 'session deleted') + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(bridge.hasSession('chat-deleted')).toBe(false) + await new Promise((resolve) => setTimeout(resolve, 1_100)) + expect(connections).toHaveLength(1) + + bridge.destroy() + }) + + it('resetSession clears the handler chain', async () => { + const bridge = new WsBridge(serverUrl, 'test') + bridge.onServerMessage('chat-reset', () => {}) + bridge.connectSession('chat-reset', 'sess-reset') + await bridge.waitForOpen('chat-reset') + + bridge.resetSession('chat-reset') + expect(bridge.hasSession('chat-reset')).toBe(false) + + bridge.destroy() + }) +}) diff --git a/adapters/common/attachment/__tests__/attachment-limits.test.ts b/adapters/common/attachment/__tests__/attachment-limits.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..32cea65398d3690853d3a690602704f8a197cd37 --- /dev/null +++ b/adapters/common/attachment/__tests__/attachment-limits.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'bun:test' +import { + checkAttachmentLimit, + IMAGE_MAX_BYTES, + FILE_MAX_BYTES, + IMAGE_MIME_WHITELIST, +} from '../attachment-limits.js' + +describe('checkAttachmentLimit', () => { + it('accepts a 1 MB PNG image', () => { + const result = checkAttachmentLimit('image', 1024 * 1024, 'image/png') + expect(result.ok).toBe(true) + }) + + it('rejects an 11 MB image as too_large', () => { + const result = checkAttachmentLimit('image', 11 * 1024 * 1024, 'image/png') + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe('too_large') + expect(result.hint).toContain('10') + } + }) + + it('rejects an unsupported image mime', () => { + const result = checkAttachmentLimit('image', 500_000, 'image/svg+xml') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('unsupported_mime') + }) + + it('rejects image/heic (not supported by Claude API)', () => { + const result = checkAttachmentLimit('image', 500_000, 'image/heic') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('unsupported_mime') + }) + + it('accepts a 10 MB PDF file', () => { + const result = checkAttachmentLimit('file', 10 * 1024 * 1024, 'application/pdf') + expect(result.ok).toBe(true) + }) + + it('rejects a 31 MB file as too_large', () => { + const result = checkAttachmentLimit('file', 31 * 1024 * 1024, 'application/pdf') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('too_large') + }) + + it('exposes the limits as exports', () => { + expect(IMAGE_MAX_BYTES).toBe(10 * 1024 * 1024) + expect(FILE_MAX_BYTES).toBe(30 * 1024 * 1024) + expect(IMAGE_MIME_WHITELIST).toContain('image/png') + }) +}) diff --git a/adapters/common/attachment/__tests__/attachment-store.test.ts b/adapters/common/attachment/__tests__/attachment-store.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b54c6b6506da6098ea813c20002c84c6bcb9038 --- /dev/null +++ b/adapters/common/attachment/__tests__/attachment-store.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as fsSync from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import { AttachmentStore } from '../attachment-store.js' + +let tmpRoot: string + +beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'att-store-test-')) +}) + +afterEach(async () => { + await fs.rm(tmpRoot, { recursive: true, force: true }) +}) + +describe('AttachmentStore', () => { + it('writes a buffer and returns the absolute path', async () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const target = store.resolvePath('feishu', 'sess-1', 'hello.png') + const written = await store.write(target, Buffer.from('PNGDATA')) + expect(path.isAbsolute(written)).toBe(true) + const content = await fs.readFile(written) + expect(content.toString()).toBe('PNGDATA') + }) + + it('writes under {root}/{platform}/{sessionId}/', async () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const target = store.resolvePath('telegram', 'sess-42', 'foo.pdf') + expect(target).toContain(path.join('telegram', 'sess-42')) + expect(target.endsWith('foo.pdf')).toBe(true) + }) + + it('sanitizes unsafe filenames (strips path separators and ..)', async () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const target = store.resolvePath('feishu', 'sess-1', '../../etc/passwd') + // The resulting target must still live inside the store root. + const root = path.resolve(tmpRoot) + expect(path.resolve(target).startsWith(root)).toBe(true) + expect(path.basename(target)).not.toContain('..') + expect(path.basename(target)).not.toContain('/') + }) + + it('collapses name collisions by prefixing timestamps', async () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const a = store.resolvePath('feishu', 'sess-1', 'image.png') + await store.write(a, Buffer.from('first')) + const b = store.resolvePath('feishu', 'sess-1', 'image.png') + expect(b).not.toBe(a) + await store.write(b, Buffer.from('second')) + const contentB = await fs.readFile(b) + expect(contentB.toString()).toBe('second') + }) + + it('gc() removes files older than retentionMs and reports counts', async () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 50 }) + const target = store.resolvePath('feishu', 'sess-1', 'stale.png') + await store.write(target, Buffer.from('STALE')) + // Age the file manually + const past = new Date(Date.now() - 10_000) + await fs.utimes(target, past, past) + const result = await store.gc() + expect(result.removed).toBe(1) + expect(result.bytes).toBe(5) + await expect(fs.access(target)).rejects.toThrow() + }) + + it('gc() keeps fresh files', async () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const target = store.resolvePath('feishu', 'sess-1', 'fresh.png') + await store.write(target, Buffer.from('FRESH')) + const result = await store.gc() + expect(result.removed).toBe(0) + await fs.access(target) + }) + + it('resolvePath under heavy collision pressure returns unique paths', () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + // First create a file so subsequent resolves hit the collision branch + const a = store.resolvePath('feishu', 'sess-1', 'race.png') + fsSync.writeFileSync(a, 'first') + // Collect 50 resolved paths in a tight loop — none should clash + const seen = new Set() + for (let i = 0; i < 50; i++) { + seen.add(store.resolvePath('feishu', 'sess-1', 'race.png')) + } + expect(seen.size).toBe(50) + for (const p of seen) { + expect(p).not.toBe(a) + } + }) + + it('gc() cleans orphan .part files after a short grace period', async () => { + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 10_000, orphanGraceMs: 50 }) + // Simulate a crashed write — leave a .part tmp file behind + const dir = path.join(tmpRoot, 'feishu', 'sess-1') + await fs.mkdir(dir, { recursive: true }) + const orphan = path.join(dir, 'image.png.1234.5678.part') + await fs.writeFile(orphan, 'ORPHAN') + // Age the orphan so gc considers it stale + const past = new Date(Date.now() - 1000) + await fs.utimes(orphan, past, past) + const result = await store.gc() + expect(result.removed).toBeGreaterThanOrEqual(1) + await expect(fs.access(orphan)).rejects.toThrow() + }) +}) diff --git a/adapters/common/attachment/__tests__/image-block-watcher.test.ts b/adapters/common/attachment/__tests__/image-block-watcher.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2fac9ef9a28c3ce0e4ee0e233322a41ae38c346f --- /dev/null +++ b/adapters/common/attachment/__tests__/image-block-watcher.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'bun:test' +import { ImageBlockWatcher } from '../image-block-watcher.js' + +describe('ImageBlockWatcher', () => { + it('extracts a markdown image with http URL', () => { + const w = new ImageBlockWatcher() + const out = w.feed('Here is ![alt](https://example.com/foo.png) an image.') + expect(out.length).toBe(1) + const source = out[0]!.source + expect(source.kind).toBe('url') + if (source.kind === 'url') { + expect(source.url).toBe('https://example.com/foo.png') + } + expect(out[0]!.alt).toBe('alt') + }) + + it('extracts a markdown image with absolute local path', () => { + const w = new ImageBlockWatcher() + const out = w.feed('![cat](/tmp/cat.jpg)') + expect(out.length).toBe(1) + const source = out[0]!.source + expect(source.kind).toBe('path') + if (source.kind === 'path') { + expect(source.path).toBe('/tmp/cat.jpg') + } + }) + + it('extracts a markdown image with file:// URL as path', () => { + const w = new ImageBlockWatcher() + const out = w.feed('![x](file:///var/img/x.png)') + const source = out[0]!.source + expect(source.kind).toBe('path') + if (source.kind === 'path') expect(source.path).toBe('/var/img/x.png') + }) + + it('extracts a data URI as base64', () => { + const w = new ImageBlockWatcher() + const out = w.feed('![inline](data:image/png;base64,AAAA)') + const source = out[0]!.source + expect(source.kind).toBe('base64') + if (source.kind === 'base64') { + expect(source.mime).toBe('image/png') + expect(source.data).toBe('AAAA') + } + }) + + it('deduplicates the same image across multiple feeds', () => { + const w = new ImageBlockWatcher() + const a = w.feed('![](https://x/y.png)') + const b = w.feed(' repeated ![](https://x/y.png) again') + expect(a.length).toBe(1) + expect(b.length).toBe(0) + }) + + it('handles images split across feed boundaries', () => { + const w = new ImageBlockWatcher() + const a = w.feed('a ![al') + const b = w.feed('t](/tmp/x.png) b') + expect(a.length).toBe(0) + expect(b.length).toBe(1) + const source = b[0]!.source + expect(source.kind).toBe('path') + if (source.kind === 'path') expect(source.path).toBe('/tmp/x.png') + }) + + it('skips non-image markdown links', () => { + const w = new ImageBlockWatcher() + const out = w.feed('See [docs](https://example.com).') + expect(out.length).toBe(0) + }) + + it('drain() returns all accumulated uploads', () => { + const w = new ImageBlockWatcher() + w.feed('![a](/tmp/a.png)') + w.feed(' and ![b](/tmp/b.png)') + const all = w.drain() + expect(all.length).toBe(2) + }) + + it('reset() clears buffer, seen set, and accumulated list', () => { + const w = new ImageBlockWatcher() + w.feed('![a](/tmp/a.png)') + w.reset() + // After reset, drain() is empty + expect(w.drain().length).toBe(0) + // And re-feeding the same image yields a fresh emit (dedup state cleared) + const out = w.feed('![a](/tmp/a.png)') + expect(out.length).toBe(1) + }) + + it('skips relative paths (cannot be resolved safely)', () => { + const w = new ImageBlockWatcher() + const out = w.feed('![rel](relative/path.png) and ![ok](/tmp/ok.png)') + expect(out.length).toBe(1) + const source = out[0]!.source + expect(source.kind).toBe('path') + if (source.kind === 'path') expect(source.path).toBe('/tmp/ok.png') + }) + + it('extracts multiple images from a single feed chunk in order', () => { + const w = new ImageBlockWatcher() + const out = w.feed('![a](/tmp/a.png) ![b](https://x/b.png) ![c](data:image/png;base64,QQ==)') + expect(out.length).toBe(3) + expect(out[0]!.source.kind).toBe('path') + expect(out[1]!.source.kind).toBe('url') + expect(out[2]!.source.kind).toBe('base64') + }) + + it('rejects malformed data URI (not base64)', () => { + const w = new ImageBlockWatcher() + const out = w.feed('![bad](data:image/png,ABC)') + // Not in `;base64,` form → classify returns null → skipped + expect(out.length).toBe(0) + }) +}) diff --git a/adapters/common/attachment/attachment-limits.ts b/adapters/common/attachment/attachment-limits.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3e87287e9bc807d4baf63b13b446fdbcd925e1d --- /dev/null +++ b/adapters/common/attachment/attachment-limits.ts @@ -0,0 +1,58 @@ +/** + * Size and MIME restrictions for IM attachments. + * + * Limits chosen to sit safely under both Feishu (10 MB image / 30 MB file) + * and Telegram Bot API (10 MB image / 50 MB file), and under Claude API's + * own image size bounds. + */ + +export const IMAGE_MAX_BYTES = 10 * 1024 * 1024 // 10 MB +export const FILE_MAX_BYTES = 30 * 1024 * 1024 // 30 MB + +export const IMAGE_MIME_WHITELIST = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', +] as const + +export type LimitCheckResult = + | { ok: true } + | { ok: false; reason: 'too_large' | 'unsupported_mime'; hint: string } + +function formatMb(bytes: number): string { + return (bytes / (1024 * 1024)).toFixed(1) +} + +export function checkAttachmentLimit( + kind: 'image' | 'file', + size: number, + mime?: string, +): LimitCheckResult { + if (kind === 'image') { + if (size > IMAGE_MAX_BYTES) { + return { + ok: false, + reason: 'too_large', + hint: `📎 图片过大(${formatMb(size)} MB),请控制在 10 MB 以内`, + } + } + if (mime && !IMAGE_MIME_WHITELIST.includes(mime as (typeof IMAGE_MIME_WHITELIST)[number])) { + return { + ok: false, + reason: 'unsupported_mime', + hint: `📎 暂不支持此图片格式(${mime})`, + } + } + return { ok: true } + } + // kind === 'file' + if (size > FILE_MAX_BYTES) { + return { + ok: false, + reason: 'too_large', + hint: `📎 文件过大(${formatMb(size)} MB),请控制在 30 MB 以内`, + } + } + return { ok: true } +} diff --git a/adapters/common/attachment/attachment-store.ts b/adapters/common/attachment/attachment-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..0214f9f380a2f858d020ede7919e97ae4a69ec74 --- /dev/null +++ b/adapters/common/attachment/attachment-store.ts @@ -0,0 +1,121 @@ +/** + * Local staging directory for IM-downloaded resources. + * + * Layout: {root}/{platform}/{sessionId}/{safeName} + * Default root: ~/.claude/im-downloads + * + * Responsibilities: + * - Generate unique, safe paths from (platform, sessionId, originalName) + * - Atomic write (tmp → rename) so concurrent downloads never corrupt each other + * - GC files that haven't been touched for `retentionMs` (default 24h) + */ + +import * as fs from 'node:fs/promises' +import * as fsSync from 'node:fs' +import type { Dirent } from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import type { ImPlatform } from './attachment-types.js' + +export interface AttachmentStoreConfig { + root: string + retentionMs: number + /** Grace window before a `.part` orphan (left behind by a crashed writer) + * is eligible for GC. Default 10 minutes. */ + orphanGraceMs: number +} + +const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000 +const DEFAULT_ORPHAN_GRACE_MS = 10 * 60 * 1000 + +function defaultRoot(): string { + return path.join(os.homedir(), '.claude', 'im-downloads') +} + +/** Strip path separators / .. / control chars from a filename. */ +function sanitizeFilename(name: string): string { + // eslint-disable-next-line no-control-regex + const base = path.basename(name || '').replace(/[\x00-\x1f]/g, '') + const cleaned = base.replace(/[\/\\]/g, '_').replace(/\.\.+/g, '_') + return cleaned.trim() || 'unnamed' +} + +export class AttachmentStore { + private readonly root: string + private readonly retentionMs: number + private readonly orphanGraceMs: number + + constructor(config?: Partial) { + this.root = config?.root ?? defaultRoot() + this.retentionMs = config?.retentionMs ?? DEFAULT_RETENTION_MS + this.orphanGraceMs = config?.orphanGraceMs ?? DEFAULT_ORPHAN_GRACE_MS + } + + /** Compute the target path. Creates parent dirs on demand. + * If a file with the same name already exists, prefix with a timestamp + * to avoid clobbering. */ + resolvePath(platform: ImPlatform, sessionId: string, name: string): string { + const safeSession = sanitizeFilename(sessionId) + const dir = path.join(this.root, platform, safeSession) + fsSync.mkdirSync(dir, { recursive: true }) + const safeName = sanitizeFilename(name) + const candidate = path.join(dir, safeName) + if (!fsSync.existsSync(candidate)) return candidate + const { name: base, ext } = path.parse(safeName) + // Collisions are rare in practice, but multiple downloads landing in the + // same millisecond must still produce unique paths — append a random + // suffix so the bare timestamp alone never clashes. + const rand = Math.random().toString(36).slice(2, 8) + return path.join(dir, `${base}-${Date.now()}-${rand}${ext}`) + } + + /** Write atomically: stream to {target}.part, then rename. */ + async write(target: string, data: Buffer): Promise { + await fs.mkdir(path.dirname(target), { recursive: true }) + const tmp = `${target}.${process.pid}.${Date.now()}.part` + await fs.writeFile(tmp, data) + await fs.rename(tmp, target) + return target + } + + /** Remove files older than retentionMs. Returns summary. */ + async gc(): Promise<{ removed: number; bytes: number }> { + let removed = 0 + let bytes = 0 + const now = Date.now() + + const walk = async (dir: string): Promise => { + let entries: Dirent[] + try { + // Pass encoding explicitly so Dirent stays string-typed under + // newer @types/node where the Buffer overload becomes the default. + entries = await fs.readdir(dir, { withFileTypes: true, encoding: 'utf8' }) + } catch { + return + } + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + await walk(full) + } else if (entry.isFile()) { + try { + const stat = await fs.stat(full) + const age = now - stat.mtimeMs + const isOrphanPart = entry.name.endsWith('.part') + const threshold = isOrphanPart ? this.orphanGraceMs : this.retentionMs + if (age > threshold) { + bytes += stat.size + await fs.unlink(full) + removed++ + } + } catch { + // ignore races + } + } + } + } + + await walk(this.root).catch(() => {}) + return { removed, bytes } + } +} diff --git a/adapters/common/attachment/attachment-types.ts b/adapters/common/attachment/attachment-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..9574695b9b095a9d43d7483d9cc6947daae054b3 --- /dev/null +++ b/adapters/common/attachment/attachment-types.ts @@ -0,0 +1,29 @@ +/** + * Shared attachment types for IM adapters. + */ + +import type { AttachmentRef } from '../ws-bridge.js' +export type { AttachmentRef } + +/** Platform tag — used for local staging subdir and telemetry. */ +export type ImPlatform = 'feishu' | 'telegram' | 'wechat' | 'dingtalk' + +/** Result of downloading an IM resource into the local stage dir. */ +export interface LocalAttachment { + kind: 'image' | 'file' + name: string // original filename, or synthesized if none + path: string // absolute path on disk (under ~/.claude/im-downloads) + size: number // bytes + mimeType: string // detected or provided + buffer: Buffer // raw bytes (kept so caller can choose base64 vs path) +} + +/** Pending outbound media found in Agent stream output. */ +export interface PendingUpload { + id: string // fingerprint, used for dedup + source: + | { kind: 'base64'; data: string; mime: string } + | { kind: 'path'; path: string; mime?: string } + | { kind: 'url'; url: string; mime?: string } + alt?: string +} diff --git a/adapters/common/attachment/image-block-watcher.ts b/adapters/common/attachment/image-block-watcher.ts new file mode 100644 index 0000000000000000000000000000000000000000..cce5c8263b15e221a6fe509dd4cb2e8081e7425a --- /dev/null +++ b/adapters/common/attachment/image-block-watcher.ts @@ -0,0 +1,94 @@ +/** + * Pure, stateful extractor that watches a stream of assistant text for + * markdown image references (`![alt](source)`) and emits PendingUpload + * records. Used by IM adapters to know which images to upload to IM. + * + * - Buffers input so an image marker split across multiple feed() calls + * still gets detected. + * - Dedups by fingerprint of the source so the same image is only emitted + * once per watcher lifetime. + */ + +import type { PendingUpload } from './attachment-types.js' + +// Matches a complete markdown image: ![alt](target) +// `alt` may be empty; `target` stops at the first closing paren. +const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g + +function fingerprint(raw: string): string { + let h = 5381 + for (let i = 0; i < raw.length; i++) { + h = ((h << 5) + h) ^ raw.charCodeAt(i) + } + return (h >>> 0).toString(16) +} + +function classify(target: string): PendingUpload['source'] | null { + if (target.startsWith('data:')) { + const m = /^data:([^;,]+);base64,(.+)$/.exec(target) + if (!m) return null + return { kind: 'base64', mime: m[1]!, data: m[2]! } + } + if (target.startsWith('file://')) { + return { kind: 'path', path: target.slice('file://'.length) } + } + if (target.startsWith('http://') || target.startsWith('https://')) { + return { kind: 'url', url: target } + } + if (target.startsWith('/')) { + return { kind: 'path', path: target } + } + return null // relative paths — skip, we can't resolve them safely +} + +export class ImageBlockWatcher { + private buffer = '' + private seen = new Set() + private accumulated: PendingUpload[] = [] + + /** Feed a new chunk of streaming text; returns any NEW PendingUploads. */ + feed(chunk: string): PendingUpload[] { + this.buffer += chunk + const out: PendingUpload[] = [] + + IMAGE_RE.lastIndex = 0 + let lastConsumedEnd = 0 + let m: RegExpExecArray | null + while ((m = IMAGE_RE.exec(this.buffer)) !== null) { + const [, alt, target] = m + const source = classify(target!) + if (source) { + const id = fingerprint(`${source.kind}:${target}`) + if (!this.seen.has(id)) { + this.seen.add(id) + const pending: PendingUpload = { id, source, alt: alt || undefined } + out.push(pending) + this.accumulated.push(pending) + } + } + lastConsumedEnd = m.index + m[0].length + } + + // Preserve tail that might contain a partially-received marker. + if (lastConsumedEnd > 0) { + this.buffer = this.buffer.slice(lastConsumedEnd) + } + if (this.buffer.length > 4096) { + this.buffer = this.buffer.slice(-2048) + } + + return out + } + + /** Return everything seen so far (for end-of-stream reconciliation). */ + drain(): PendingUpload[] { + return [...this.accumulated] + } + + /** Reset watcher state (use at /clear or new session). */ + reset(): void { + this.buffer = '' + this.seen.clear() + this.accumulated = [] + } +} diff --git a/adapters/common/chat-queue.ts b/adapters/common/chat-queue.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0c0190edb6479b8a07bd043b68da811f90e9e7c --- /dev/null +++ b/adapters/common/chat-queue.ts @@ -0,0 +1,24 @@ +/** + * 会话串行队列 + * + * 同一 chatId 的消息串行处理,防并发冲突。 + * 不同 chatId 之间互不影响。 + * 参考 openclaw-lark chat-queue.ts 的 Promise 链设计。 + */ + +const queues = new Map>() + +export async function enqueue(chatId: string, fn: () => Promise): Promise { + const prev = queues.get(chatId) ?? Promise.resolve() + const next = prev.then(fn, () => fn()).catch((err) => { + console.error(`[ChatQueue] Error in task for chat ${chatId}:`, err) + }) + queues.set(chatId, next) + // Clean up after completion to avoid memory leak for one-off chats + next.finally(() => { + if (queues.get(chatId) === next) { + queues.delete(chatId) + } + }) + return next +} diff --git a/adapters/common/config.ts b/adapters/common/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..095504d51339d166b86557fbc75bbc26c1faee2f --- /dev/null +++ b/adapters/common/config.ts @@ -0,0 +1,184 @@ +/** + * Adapter 配置加载 + * + * 优先级:环境变量 > ~/.claude/adapters.json > 默认值 + */ + +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +export type PairedUser = { + userId: string | number + displayName: string + pairedAt: number +} + +export type PairingState = { + code: string | null + expiresAt: number | null + createdAt: number | null +} + +export type TelegramConfig = { + botToken: string + allowedUsers: number[] + pairedUsers: PairedUser[] + defaultWorkDir: string +} + +export type FeishuConfig = { + appId: string + appSecret: string + encryptKey: string + verificationToken: string + allowedUsers: string[] + pairedUsers: PairedUser[] + defaultWorkDir: string + streamingCard: boolean +} + +export type WechatConfig = { + accountId: string + botToken: string + baseUrl: string + userId: string + allowedUsers: string[] + pairedUsers: PairedUser[] + defaultWorkDir: string +} + +export type DingtalkConfig = { + clientId: string + clientSecret: string + allowedUsers: string[] + pairedUsers: PairedUser[] + defaultWorkDir: string + endpoint: string + permissionCardTemplateId: string +} + +export type AdapterConfig = { + serverUrl: string + defaultProjectDir: string + pairing: PairingState + telegram: TelegramConfig + feishu: FeishuConfig + wechat: WechatConfig + dingtalk: DingtalkConfig +} + +export type AdapterPlatformConfig = + | TelegramConfig + | FeishuConfig + | WechatConfig + | DingtalkConfig + +function getConfigPath(): string { + const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') + return path.join(configDir, 'adapters.json') +} + +function loadFile(): Record { + try { + return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8')) + } catch (err: any) { + if (err?.code !== 'ENOENT') { + console.warn(`[Config] Failed to parse ${getConfigPath()}, using defaults`) + } + return {} + } +} + +export function loadConfig(): AdapterConfig { + const file = loadFile() + const tg = file.telegram ?? {} + const fs_ = file.feishu ?? {} + const wc = file.wechat ?? {} + const dt = file.dingtalk ?? {} + const pairing = file.pairing ?? {} + const fallbackWorkDir = resolveUserDefaultWorkDir() + + return { + serverUrl: process.env.ADAPTER_SERVER_URL || file.serverUrl || 'ws://127.0.0.1:3456', + defaultProjectDir: file.defaultProjectDir || '', + pairing: { + code: pairing.code ?? null, + expiresAt: pairing.expiresAt ?? null, + createdAt: pairing.createdAt ?? null, + }, + telegram: { + botToken: process.env.TELEGRAM_BOT_TOKEN || tg.botToken || '', + allowedUsers: tg.allowedUsers ?? [], + pairedUsers: tg.pairedUsers ?? [], + defaultWorkDir: tg.defaultWorkDir || fallbackWorkDir, + }, + feishu: { + appId: process.env.FEISHU_APP_ID || fs_.appId || '', + appSecret: process.env.FEISHU_APP_SECRET || fs_.appSecret || '', + encryptKey: process.env.FEISHU_ENCRYPT_KEY || fs_.encryptKey || '', + verificationToken: process.env.FEISHU_VERIFICATION_TOKEN || fs_.verificationToken || '', + allowedUsers: fs_.allowedUsers ?? [], + pairedUsers: fs_.pairedUsers ?? [], + defaultWorkDir: fs_.defaultWorkDir || fallbackWorkDir, + streamingCard: fs_.streamingCard ?? false, + }, + wechat: { + accountId: process.env.WECHAT_ACCOUNT_ID || wc.accountId || '', + botToken: process.env.WECHAT_BOT_TOKEN || wc.botToken || '', + baseUrl: process.env.WECHAT_BASE_URL || wc.baseUrl || 'https://ilinkai.weixin.qq.com', + userId: process.env.WECHAT_USER_ID || wc.userId || '', + allowedUsers: wc.allowedUsers ?? [], + pairedUsers: wc.pairedUsers ?? [], + defaultWorkDir: wc.defaultWorkDir || fallbackWorkDir, + }, + dingtalk: { + clientId: process.env.DINGTALK_CLIENT_ID || dt.clientId || '', + clientSecret: process.env.DINGTALK_CLIENT_SECRET || dt.clientSecret || '', + allowedUsers: dt.allowedUsers ?? [], + pairedUsers: dt.pairedUsers ?? [], + defaultWorkDir: dt.defaultWorkDir || fallbackWorkDir, + endpoint: process.env.DINGTALK_STREAM_ENDPOINT || dt.endpoint || 'https://api.dingtalk.com', + permissionCardTemplateId: process.env.DINGTALK_PERMISSION_CARD_TEMPLATE_ID || dt.permissionCardTemplateId || '', + }, + } +} + +export function getConfiguredWorkDir(config: AdapterConfig, platformConfig: AdapterPlatformConfig): string { + return config.defaultProjectDir || platformConfig.defaultWorkDir +} + +function resolveUserDefaultWorkDir(): string { + const candidates = [ + process.env.ADAPTER_DEFAULT_PROJECT_DIR, + process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR, + process.env.PWD, + process.cwd(), + os.homedir(), + ] + + for (const candidate of candidates) { + const resolved = resolveExistingDirectory(candidate) + if (resolved) return resolved + } + + return os.homedir() +} + +function resolveExistingDirectory(value: string | undefined): string | null { + const trimmed = value?.trim() + if (!trimmed) return null + + const expanded = trimmed === '~' + ? os.homedir() + : trimmed.startsWith('~/') + ? path.join(os.homedir(), trimmed.slice(2)) + : trimmed + + try { + const realPath = fs.realpathSync(expanded) + return fs.statSync(realPath).isDirectory() ? realPath : null + } catch { + return null + } +} diff --git a/adapters/common/format.ts b/adapters/common/format.ts new file mode 100644 index 0000000000000000000000000000000000000000..d011d67a6f84d9e5e36bdc8e797dd02ca5561d63 --- /dev/null +++ b/adapters/common/format.ts @@ -0,0 +1,323 @@ +/** + * 消息格式化工具 + */ + +type AdapterChatState = + | 'idle' + | 'thinking' + | 'streaming' + | 'tool_executing' + | 'permission_pending' + +type ImStatusSummary = { + sessionId?: string + projectName?: string | null + branch?: string | null + model?: string | null + state?: AdapterChatState | null + verb?: string | null + pendingPermissionCount?: number + taskCounts?: { + total: number + pending: number + inProgress: number + completed: number + } +} + +const IM_HELP_LINES = [ + '/new [项目] / 新会话 — 新建会话或切换项目', + '/projects / 项目列表 — 查看最近项目', + '/status / 状态 — 查看当前会话状态', + '/clear / 清空 — 清空当前会话上下文', + '/stop / 停止 — 停止当前生成', + '/help / 帮助 — 显示这份帮助', + '权限审批:/allow 、/always 、/deny ', +] + +/** Split text into chunks that fit within a character limit, respecting paragraph/sentence boundaries. */ +export function splitMessage(text: string, limit: number): string[] { + if (text.length <= limit) return [text] + + const chunks: string[] = [] + let remaining = text + + while (remaining.length > 0) { + if (remaining.length <= limit) { + chunks.push(remaining) + break + } + + let splitAt = remaining.lastIndexOf('\n\n', limit) + if (splitAt <= 0) splitAt = remaining.lastIndexOf('\n', limit) + if (splitAt <= 0) splitAt = remaining.lastIndexOf('. ', limit) + if (splitAt <= 0) splitAt = remaining.lastIndexOf(' ', limit) + if (splitAt <= 0) splitAt = limit + + // Include the delimiter for paragraph/sentence breaks + if (remaining[splitAt] === '\n' || remaining[splitAt] === '.') splitAt += 1 + + chunks.push(remaining.slice(0, splitAt).trimEnd()) + remaining = remaining.slice(splitAt).trimStart() + } + + return chunks +} + +type MarkdownTable = { + headers: string[] + rows: string[][] +} + +function splitMarkdownTableRow(line: string): string[] { + const trimmed = line.trim() + const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed + const withoutTrailingPipe = inner.endsWith('|') ? inner.slice(0, -1) : inner + return withoutTrailingPipe.split('|').map((cell) => cell.trim()) +} + +function isMarkdownTableDivider(line: string): boolean { + const cells = splitMarkdownTableRow(line) + if (cells.length < 2) return false + return cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())) +} + +function isPotentialMarkdownTableRow(line: string): boolean { + const trimmed = line.trim() + return trimmed.includes('|') && splitMarkdownTableRow(trimmed).length >= 2 +} + +function isFenceMarker(line: string): boolean { + return /^\s*(```|~~~)/.test(line) +} + +function formatMarkdownTableAsBullets(table: MarkdownTable): string { + const { headers, rows } = table + if (headers.length === 0 || rows.length === 0) return '' + + const output: string[] = [] + + for (const row of rows) { + if (row.every((cell) => !cell)) continue + + const label = row[0] + if (label) output.push(label) + + for (let i = 1; i < Math.max(headers.length, row.length); i++) { + const value = row[i] + if (!value) continue + const header = headers[i] + output.push(`• ${header ? `${header}: ` : `Column ${i}: `}${value}`) + } + + if (output[output.length - 1] !== '') output.push('') + } + + while (output[output.length - 1] === '') output.pop() + return output.join('\n') +} + +/** Convert GitHub-flavored Markdown pipe tables into mobile-friendly bullet lists. */ +export function convertMarkdownTablesToBullets(markdown: string): string { + const lines = markdown.split('\n') + const output: string[] = [] + let inFence = false + let i = 0 + + while (i < lines.length) { + const headerLine = lines[i] ?? '' + + if (isFenceMarker(headerLine)) { + inFence = !inFence + output.push(headerLine) + i += 1 + continue + } + + const dividerLine = lines[i + 1] ?? '' + if (!inFence && isPotentialMarkdownTableRow(headerLine) && isMarkdownTableDivider(dividerLine)) { + const headers = splitMarkdownTableRow(headerLine) + const rows: string[][] = [] + i += 2 + + while (i < lines.length && isPotentialMarkdownTableRow(lines[i] ?? '')) { + rows.push(splitMarkdownTableRow(lines[i] ?? '')) + i += 1 + } + + const rendered = formatMarkdownTableAsBullets({ headers, rows }) + if (rendered) output.push(rendered) + continue + } + + output.push(headerLine) + i += 1 + } + + return output.join('\n') +} + +/** Format tool use info for display in IM. */ +export function formatToolUse(toolName: string, input: unknown): string { + const inp = (input && typeof input === 'object' ? input : {}) as Record + const summary = formatToolSummary(toolName, inp) + if (summary) return `🔧 ${toolName} ${summary}` + const preview = truncateInput(input, 200) + return `🔧 ${toolName}\n${preview}` +} + +/** Generate a concise human-readable summary for common tools. */ +function formatToolSummary(tool: string, inp: Record): string | null { + switch (tool) { + case 'Bash': { + const desc = inp.description as string | undefined + const cmd = inp.command as string | undefined + if (desc) return desc + if (cmd) return truncate(cmd, 120) + return null + } + case 'Read': { + const fp = inp.file_path as string | undefined + if (fp) return shortPath(fp) + return null + } + case 'Edit': { + const fp = inp.file_path as string | undefined + if (fp) return shortPath(fp) + return null + } + case 'Write': { + const fp = inp.file_path as string | undefined + if (fp) return shortPath(fp) + return null + } + case 'Grep': { + const pat = inp.pattern as string | undefined + const p = inp.path as string | undefined + if (pat) return `"${truncate(pat, 60)}"` + (p ? ` in ${shortPath(p)}` : '') + return null + } + case 'Glob': { + const pat = inp.pattern as string | undefined + return pat ? `"${pat}"` : null + } + case 'Skill': { + const skill = inp.skill as string | undefined + return skill || null + } + case 'Agent': { + const desc = inp.description as string | undefined + return desc || null + } + case 'WebFetch': { + const url = inp.url as string | undefined + return url ? truncate(url, 120) : null + } + case 'WebSearch': { + const q = inp.query as string | undefined + return q ? `"${truncate(q, 80)}"` : null + } + default: + return null + } +} + +function shortPath(fp: string): string { + const parts = fp.split('/') + return parts.length > 3 ? '…/' + parts.slice(-3).join('/') : fp +} + +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max) + '…' : s +} + +/** Format a permission request for display in IM. */ +export function formatPermissionRequest(toolName: string, input: unknown, requestId: string): string { + const preview = truncateInput(input, 300) + return `🔐 需要权限确认 [${requestId}]\n工具: ${toolName}\n${preview}` +} + +/** Truncate tool input to a preview string. */ +export function truncateInput(input: unknown, maxLen: number): string { + try { + const s = typeof input === 'string' ? input : JSON.stringify(input, null, 2) + return s.length > maxLen ? s.slice(0, maxLen) + '…' : s + } catch { + return '(unserializable)' + } +} + +/** Escape special characters for Telegram MarkdownV2. */ +export function escapeMarkdownV2(text: string): string { + return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, '\\$1') +} + +export function formatImHelp(): string { + return `可用命令:\n\n${IM_HELP_LINES.join('\n')}` +} + +export function formatImStatus(summary: ImStatusSummary | null): string { + if (!summary?.sessionId) { + return '当前没有活动会话。\n\n发送 /new 新建会话,或发送 /projects 选择项目。' + } + + const lines = ['当前会话状态:'] + + if (summary.projectName) { + lines.push(`项目: ${summary.projectName}${summary.branch ? ` (${summary.branch})` : ''}`) + } else if (summary.branch) { + lines.push(`分支: ${summary.branch}`) + } + + lines.push(`会话: ${shortSessionId(summary.sessionId)}`) + + if (summary.model) { + lines.push(`模型: ${summary.model}`) + } + + lines.push(`状态: ${formatAdapterChatState(summary.state, summary.verb)}`) + + const pendingPermissionCount = summary.pendingPermissionCount ?? 0 + if (pendingPermissionCount > 0) { + lines.push(`审批: ${pendingPermissionCount} 个待确认`) + } + + const taskCounts = summary.taskCounts + if (taskCounts && taskCounts.total > 0) { + const taskParts = [`总计 ${taskCounts.total}`] + if (taskCounts.inProgress > 0) taskParts.push(`进行中 ${taskCounts.inProgress}`) + if (taskCounts.pending > 0) taskParts.push(`待处理 ${taskCounts.pending}`) + if (taskCounts.completed > 0) taskParts.push(`已完成 ${taskCounts.completed}`) + lines.push(`任务: ${taskParts.join(' · ')}`) + } + + return lines.join('\n') +} + +function formatAdapterChatState( + state: AdapterChatState | null | undefined, + verb: string | null | undefined, +): string { + const label = (() => { + switch (state) { + case 'thinking': + return '思考中' + case 'streaming': + return '生成中' + case 'tool_executing': + return '执行工具中' + case 'permission_pending': + return '等待权限确认' + case 'idle': + default: + return '空闲' + } + })() + + if (!verb || verb === 'Thinking') return label + return `${label} (${verb})` +} + +function shortSessionId(sessionId: string): string { + return sessionId.length > 12 ? `${sessionId.slice(0, 8)}…` : sessionId +} diff --git a/adapters/common/http-client.ts b/adapters/common/http-client.ts new file mode 100644 index 0000000000000000000000000000000000000000..61bd0bcbc06d288bf9944c1bd68200cb87ced0e6 --- /dev/null +++ b/adapters/common/http-client.ts @@ -0,0 +1,207 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +export type RecentProject = { + projectPath: string + realPath: string + projectName: string + isGit: boolean + repoName: string | null + branch: string | null + modifiedAt: string + sessionCount: number +} + +export type GitInfo = { + branch: string | null + repoName: string | null + workDir: string + changedFiles: number +} + +export type SessionTask = { + id: string + subject: string + status: 'pending' | 'in_progress' | 'completed' +} + +export class AdapterHttpClient { + readonly httpBaseUrl: string + private readonly allowedProjectRoots: string[] + /** Default timeout for HTTP requests (30 seconds) */ + private static readonly DEFAULT_TIMEOUT_MS = 30_000 + + constructor(wsUrl: string, options?: { allowedProjectRoots?: string[] }) { + this.httpBaseUrl = wsUrl + .replace(/^ws:/, 'http:') + .replace(/^wss:/, 'https:') + .replace(/\/$/, '') + this.allowedProjectRoots = (options?.allowedProjectRoots ?? []) + .map(resolveExistingProjectPath) + .filter((value): value is string => Boolean(value)) + } + + /** Create an AbortController with timeout */ + private createTimeoutController(timeoutMs = AdapterHttpClient.DEFAULT_TIMEOUT_MS): { + controller: AbortController + timer: ReturnType + } { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + return { controller, timer } + } + + async createSession(workDir: string): Promise { + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir }), + signal: controller.signal, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(`Failed to create session: ${(err as any).message}`) + } + const data = (await res.json()) as { sessionId: string } + return data.sessionId + } finally { + clearTimeout(timer) + } + } + + async listRecentProjects(): Promise { + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`, { + signal: controller.signal, + }) + if (!res.ok) { + throw new Error(`Failed to list projects: ${res.statusText}`) + } + const data = (await res.json()) as { projects: RecentProject[] } + return data.projects + } finally { + clearTimeout(timer) + } + } + + /** + * Match a project by index (1-based) or fuzzy name from recent projects. + * Returns { project, ambiguous[] } — ambiguous is set when multiple projects match. + */ + async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> { + const directPath = resolveExistingProjectPath(query) + if (directPath) { + if (!isPathWithinAllowedRoots(directPath, this.allowedProjectRoots)) { + return {} + } + + return { + project: { + projectPath: directPath, + realPath: directPath, + projectName: path.basename(directPath) || directPath, + isGit: fs.existsSync(path.join(directPath, '.git')), + repoName: null, + branch: null, + modifiedAt: new Date().toISOString(), + sessionCount: 0, + }, + } + } + + const projects = await this.listRecentProjects() + + // Try as 1-based index + const num = parseInt(query, 10) + if (!isNaN(num) && num >= 1 && num <= projects.length && String(num) === query.trim()) { + return { project: projects[num - 1] } + } + + const q = query.toLowerCase() + + // Exact project name match + const exact = projects.find(p => p.projectName.toLowerCase() === q) + if (exact) return { project: exact } + + // Fuzzy: name or path contains query + const matches = projects.filter(p => + p.projectName.toLowerCase().includes(q) || + p.realPath.toLowerCase().includes(q) + ) + if (matches.length === 1) return { project: matches[0] } + if (matches.length > 1) return { ambiguous: matches } + + return {} + } + + async getGitInfo(sessionId: string): Promise { + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`, { + signal: controller.signal, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(`Failed to load git info: ${(err as any).message}`) + } + return (await res.json()) as GitInfo + } finally { + clearTimeout(timer) + } + } + + async getTasksForSession(sessionId: string): Promise { + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`, { + signal: controller.signal, + }) + if (!res.ok) { + if (res.status === 404) return [] + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(`Failed to load tasks: ${(err as any).message}`) + } + const data = (await res.json()) as { tasks?: SessionTask[] } + return Array.isArray(data.tasks) ? data.tasks : [] + } finally { + clearTimeout(timer) + } + } +} + +function isPathWithinAllowedRoots(target: string, roots: string[]): boolean { + if (roots.length === 0) return false + + for (const root of roots) { + const relative = path.relative(root, target) + if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) { + return true + } + } + + return false +} + +function resolveExistingProjectPath(query: string): string | null { + const trimmed = query.trim() + if (!trimmed) return null + + const expanded = trimmed === '~' + ? os.homedir() + : trimmed.startsWith('~/') + ? path.join(os.homedir(), trimmed.slice(2)) + : trimmed + + if (!path.isAbsolute(expanded)) return null + + try { + const realPath = fs.realpathSync(expanded) + return fs.statSync(realPath).isDirectory() ? realPath : null + } catch { + return null + } +} diff --git a/adapters/common/message-buffer.ts b/adapters/common/message-buffer.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5a5006a62483cafeab9a754d9f6ecdccc79476b --- /dev/null +++ b/adapters/common/message-buffer.ts @@ -0,0 +1,100 @@ +/** + * 流式消息缓冲 + * + * 将 content_delta 累积后按时间窗口或字符数批量 flush。 + * 用于 Telegram editMessage / 飞书流式卡片更新。 + */ + +export type FlushCallback = (text: string, isComplete: boolean) => void | Promise + +const DEFAULT_INTERVAL_MS = 500 +const DEFAULT_CHAR_THRESHOLD = 200 + +export class MessageBuffer { + private buffer = '' + private timer: ReturnType | null = null + private flushing = false + private pendingComplete = false + private activeFlush: Promise | null = null + + constructor( + private onFlush: FlushCallback, + private intervalMs = DEFAULT_INTERVAL_MS, + private charThreshold = DEFAULT_CHAR_THRESHOLD, + ) {} + + /** Append text delta. Triggers flush if threshold reached. */ + append(text: string): void { + this.buffer += text + if (this.buffer.length >= this.charThreshold) { + this.scheduleFlush() + } else if (!this.timer) { + this.timer = setTimeout(() => this.flush(false), this.intervalMs) + } + } + + /** Immediately flush all remaining content (called on message_complete). */ + async complete(): Promise { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + if (this.flushing) { + // A flush is in-flight; mark pending so it fires after current flush finishes + this.pendingComplete = true + await this.activeFlush + return + } + await this.flush(true) + } + + /** Reset the buffer for a new message. */ + reset(): void { + this.buffer = '' + this.pendingComplete = false + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } + + private scheduleFlush(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + queueMicrotask(() => this.flush(false)) + } + + private async flush(isComplete: boolean): Promise { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + if (this.flushing) { + await this.activeFlush + return + } + if (this.buffer.length === 0) return + + this.flushing = true + const text = this.buffer + this.buffer = '' + this.activeFlush = (async () => { + try { + await this.onFlush(text, isComplete) + } catch (err) { + console.error('[MessageBuffer] Flush error:', err) + } finally { + this.flushing = false + this.activeFlush = null + // If complete() was called while we were flushing, do the final flush now. + if (this.pendingComplete) { + this.pendingComplete = false + await this.flush(true) + } + } + })() + await this.activeFlush + } +} diff --git a/adapters/common/message-dedup.ts b/adapters/common/message-dedup.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c8bf409450f994a3546aec90f89f03ae390b6a7 --- /dev/null +++ b/adapters/common/message-dedup.ts @@ -0,0 +1,57 @@ +/** + * 消息去重 + * + * 防止 WebSocket 重连等场景下消息重复处理。 + * 参考 openclaw-lark dedup.ts 的 Map + TTL + 容量 设计。 + */ + +const DEFAULT_TTL_MS = 10 * 60_000 // 10 minutes +const DEFAULT_MAX_ENTRIES = 5000 +const SWEEP_INTERVAL_MS = 60_000 // 1 minute + +export class MessageDedup { + private store = new Map() + private sweepTimer: ReturnType + + constructor( + private ttlMs = DEFAULT_TTL_MS, + private maxEntries = DEFAULT_MAX_ENTRIES, + ) { + this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS) + } + + /** Returns true if this is a NEW message, false if duplicate. */ + tryRecord(id: string): boolean { + const now = Date.now() + const existing = this.store.get(id) + + if (existing !== undefined && now - existing < this.ttlMs) { + return false // duplicate + } + + // Evict oldest if at capacity + if (this.store.size >= this.maxEntries) { + const oldest = this.store.keys().next().value + if (oldest !== undefined) this.store.delete(oldest) + } + + this.store.set(id, now) + return true + } + + private sweep(): void { + const now = Date.now() + for (const [key, ts] of this.store) { + if (now - ts >= this.ttlMs) { + this.store.delete(key) + } else { + break // Map preserves insertion order; once fresh, rest is fresh + } + } + } + + destroy(): void { + clearInterval(this.sweepTimer) + this.store.clear() + } +} diff --git a/adapters/common/pairing.ts b/adapters/common/pairing.ts new file mode 100644 index 0000000000000000000000000000000000000000..5616044eca4c490aa64406588b6445400bdd3961 --- /dev/null +++ b/adapters/common/pairing.ts @@ -0,0 +1,150 @@ +/** + * 配对核心逻辑 + * + * - generatePairingCode(): 生成 6 位安全配对码 + * - isPaired(): 检查用户是否已配对(pairedUsers + allowedUsers 并集) + * - tryPair(): 验证配对码,成功则写入 pairedUsers 并清除 code + */ + +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import * as crypto from 'node:crypto' +import type { PairedUser, PairingState } from './config.js' + +const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // 排除 0/O/1/I/L +export type ImPlatform = 'telegram' | 'feishu' | 'wechat' | 'dingtalk' + +// 速率限制:每个 userId 在 RATE_LIMIT_WINDOW_MS 内最多 RATE_LIMIT_MAX_ATTEMPTS 次失败尝试 +const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000 // 5 minutes +const RATE_LIMIT_MAX_ATTEMPTS = 5 +const failedAttempts = new Map() + +function isRateLimited(userId: string | number): boolean { + const key = String(userId) + const record = failedAttempts.get(key) + if (!record) return false + if (Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) { + failedAttempts.delete(key) + return false + } + return record.count >= RATE_LIMIT_MAX_ATTEMPTS +} + +function recordFailedAttempt(userId: string | number): void { + const key = String(userId) + const record = failedAttempts.get(key) + if (!record || Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) { + failedAttempts.set(key, { count: 1, firstAttempt: Date.now() }) + } else { + record.count++ + } +} +const CODE_LENGTH = 6 +const CODE_TTL_MS = 60 * 60 * 1000 // 60 minutes + +function getConfigPath(): string { + const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') + return path.join(configDir, 'adapters.json') +} + +function readConfigFile(): Record { + try { + return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8')) + } catch { + return {} + } +} + +function writeConfigFile(data: Record): void { + const filePath = getConfigPath() + const dir = path.dirname(filePath) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + const tmp = `${filePath}.tmp.${crypto.randomBytes(8).toString('hex')}` + fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }) + fs.renameSync(tmp, filePath) +} + +export function generatePairingCode(): string { + let code = '' + for (let i = 0; i < CODE_LENGTH; i++) { + code += SAFE_ALPHABET[crypto.randomInt(SAFE_ALPHABET.length)] + } + return code +} + +/** 检查用户是否已配对(pairedUsers + allowedUsers 并集) */ +export function isPaired( + platform: ImPlatform, + userId: string | number, + config: Record, +): boolean { + const platformConfig = config[platform] ?? {} + const allowedUsers: (string | number)[] = platformConfig.allowedUsers ?? [] + const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? [] + + // allowedUsers 非空时检查 + if (allowedUsers.length > 0 && allowedUsers.includes(userId)) return true + // 默认关闭:没有配置任何用户时拒绝访问(需要先配对) + if (pairedUsers.length === 0 && allowedUsers.length === 0) return false + + return pairedUsers.some((p) => String(p.userId) === String(userId)) +} + +/** + * 尝试配对:验证消息文本是否匹配当前有效配对码。 + * 成功则写入 pairedUsers 并清除 pairing.code,返回 true。 + */ +export function tryPair( + messageText: string, + senderInfo: { userId: string | number; displayName: string }, + platform: ImPlatform, +): boolean { + const file = readConfigFile() + const pairing: PairingState = file.pairing ?? { code: null, expiresAt: null, createdAt: null } + + // 速率限制检查 + if (isRateLimited(senderInfo.userId)) return false + + // 检查配对码是否有效 + if (!pairing.code || !pairing.expiresAt) return false + if (Date.now() > pairing.expiresAt) return false + + // 比较(忽略大小写和空格) + const input = messageText.trim().toUpperCase() + if (input !== pairing.code.toUpperCase()) { + recordFailedAttempt(senderInfo.userId) + return false + } + + // 配对成功:写入 pairedUsers + const platformConfig = file[platform] ?? {} + const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? [] + + // 避免重复 + const exists = pairedUsers.some((p) => String(p.userId) === String(senderInfo.userId)) + if (!exists) { + pairedUsers.push({ + userId: senderInfo.userId, + displayName: senderInfo.displayName, + pairedAt: Date.now(), + }) + } + + // 更新 config + file[platform] = { ...platformConfig, pairedUsers } + file.pairing = { code: null, expiresAt: null, createdAt: null } // 一次性使用 + writeConfigFile(file) + + return true +} + +/** 统一的用户授权检查(供各 adapter 调用) */ +export function isAllowedUser(platform: ImPlatform, userId: string | number): boolean { + try { + const cfgFile = readConfigFile() + return isPaired(platform, userId, cfgFile) + } catch { + return false + } +} diff --git a/adapters/common/permission.ts b/adapters/common/permission.ts new file mode 100644 index 0000000000000000000000000000000000000000..2937d79666e4ed8abd7cda24a5b1aae36c55b375 --- /dev/null +++ b/adapters/common/permission.ts @@ -0,0 +1,70 @@ +export type PermissionDecision = { + requestId: string + allowed: boolean + rule?: 'always' +} + +function getSinglePendingRequestId(requestIds?: Iterable | null): string | null { + if (!requestIds) return null + const ids = Array.from(requestIds) + return ids.length === 1 ? ids[0]! : null +} + +export function parsePermissionCommand( + text: string, + pendingRequestIds?: Iterable | null, +): PermissionDecision | null { + const trimmed = text.trim() + const match = text.trim().match(/^\/(allow|always|allow-always|deny)\s+(\S+)/i) + if (match) { + const action = match[1]!.toLowerCase() + const requestId = match[2]! + if (action === 'deny') return { requestId, allowed: false } + if (action === 'always' || action === 'allow-always') return { requestId, allowed: true, rule: 'always' } + return { requestId, allowed: true } + } + + const requestId = getSinglePendingRequestId(pendingRequestIds) + if (!requestId) return null + + const shortcut = trimmed.toLowerCase() + if (['1', '/1', 'allow', '/allow', 'y', 'yes', '允许', '允许一次', '同意', '批准'].includes(shortcut)) { + return { requestId, allowed: true } + } + if (['2', '/2', 'always', '/always', 'allow-always', '/allow-always', '永久允许', '一直允许'].includes(shortcut)) { + return { requestId, allowed: true, rule: 'always' } + } + if (['3', '/3', 'deny', '/deny', 'n', 'no', '拒绝', '不允许', '否'].includes(shortcut)) { + return { requestId, allowed: false } + } + + return null +} + +export function parsePermitCallbackData(data: string): PermissionDecision | null { + const parts = data.split(':') + if (parts.length !== 3 || parts[0] !== 'permit' || !parts[1]) return null + + switch (parts[2]) { + case 'yes': + return { requestId: parts[1], allowed: true } + case 'always': + return { requestId: parts[1], allowed: true, rule: 'always' } + case 'no': + return { requestId: parts[1], allowed: false } + default: + return null + } +} + +export function formatPermissionInstructions(requestId: string): string { + return [ + '回复 1 允许一次,2 永久允许,3 拒绝。', + `也可回复 /allow ${requestId}、/always ${requestId}、/deny ${requestId}。`, + ].join('\n') +} + +export function formatPermissionDecisionStatus(decision: Pick): string { + if (!decision.allowed) return '❌ 已拒绝' + return decision.rule === 'always' ? '♾️ 已永久允许' : '✅ 已允许' +} diff --git a/adapters/common/session-store.ts b/adapters/common/session-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ca4f16c46418bc2fdadc011555fed07dd708cd3 --- /dev/null +++ b/adapters/common/session-store.ts @@ -0,0 +1,82 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' + +export type SessionEntry = { + sessionId: string + workDir: string + updatedAt: number +} + +type StoreData = Record + +function getDefaultPath(): string { + const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') + return path.join(configDir, 'adapter-sessions.json') +} + +export class SessionStore { + private data: StoreData + private filePath: string + + constructor(filePath?: string) { + this.filePath = filePath ?? getDefaultPath() + this.data = this.load() + } + + get(chatId: string): SessionEntry | null { + this.refresh() + return this.data[chatId] ?? null + } + + set(chatId: string, sessionId: string, workDir: string): void { + this.refresh() + this.data[chatId] = { sessionId, workDir, updatedAt: Date.now() } + this.save() + } + + delete(chatId: string): void { + this.refresh() + delete this.data[chatId] + this.save() + } + + deleteBySessionId(sessionId: string): string[] { + this.refresh() + const removed: string[] = [] + for (const [chatId, entry] of Object.entries(this.data)) { + if (entry.sessionId !== sessionId) continue + delete this.data[chatId] + removed.push(chatId) + } + if (removed.length > 0) { + this.save() + } + return removed + } + + listAll(): Array<{ chatId: string } & SessionEntry> { + this.refresh() + return Object.entries(this.data).map(([chatId, entry]) => ({ chatId, ...entry })) + } + + private refresh(): void { + this.data = this.load() + } + + private load(): StoreData { + try { + return JSON.parse(fs.readFileSync(this.filePath, 'utf-8')) + } catch { + return {} + } + } + + private save(): void { + const dir = path.dirname(this.filePath) + fs.mkdirSync(dir, { recursive: true }) + const tmp = `${this.filePath}.tmp.${Date.now()}` + fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2) + '\n') + fs.renameSync(tmp, this.filePath) + } +} diff --git a/adapters/common/ws-bridge.ts b/adapters/common/ws-bridge.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ccc6ca5862438488e1f9ee38fc84b9667f12153 --- /dev/null +++ b/adapters/common/ws-bridge.ts @@ -0,0 +1,289 @@ +/** + * WebSocket Bridge + * + * 封装与 Claude Code Desktop 服务端 /ws/:sessionId 的通信。 + * 管理 chatId → sessionId 映射,自动重连,心跳。 + */ + +import WebSocket from 'ws' + +/** Attachment reference — mirrors src/server/ws/events.ts AttachmentRef. + * The server will either (a) write base64 `data` to + * ~/.claude/uploads/{sessionId}/ and convert to ImageBlockParam, or + * (b) read `path` from disk and inject `@"path"` into the prompt. */ +export type AttachmentRef = { + type: 'file' | 'image' + name?: string + path?: string + data?: string // base64 payload (images) + mimeType?: string +} + +/** Server → Client message (mirrors src/server/ws/events.ts ServerMessage) */ +export type ServerMessage = { + type: string + [key: string]: any +} + +/** Callback for server messages */ +export type MessageHandler = (msg: ServerMessage) => void + +type Session = { + sessionId: string + ws: WebSocket + reconnectAttempts: number + reconnectTimer: ReturnType | null +} + +const HEARTBEAT_INTERVAL_MS = 30_000 +const RECONNECT_BASE_MS = 1000 +const RECONNECT_MAX_MS = 30_000 +const MAX_RECONNECT_ATTEMPTS = 10 + +export class WsBridge { + private sessions = new Map() + /** Single handler per chatId — separate from sessions so reconnect doesn't duplicate */ + private handlers = new Map() + /** Per-chat FIFO queue of in-flight handler promises. + * Ensures an async handler for message N completes before handler for N+1 + * starts, preventing state races at `await` points. */ + private handlerChains = new Map>() + private serverUrl: string + private platform: string + private heartbeatTimer: ReturnType | null = null + private destroyed = false + + constructor(serverUrl: string, platform: string) { + this.serverUrl = serverUrl.replace(/\/$/, '') + this.platform = platform + this.startHeartbeat() + } + + /** Connect to a session with a known sessionId. Returns false if already connected. */ + connectSession(chatId: string, sessionId: string): boolean { + const existing = this.sessions.get(chatId) + if (existing && existing.ws.readyState === WebSocket.OPEN) { + return false + } + this.connect(chatId, sessionId) + return true + } + + /** Send a user message to the session bound to chatId. */ + sendUserMessage( + chatId: string, + content: string, + attachments?: AttachmentRef[], + ): boolean { + const payload: Record = { type: 'user_message', content } + if (attachments && attachments.length > 0) { + payload.attachments = attachments + } + return this.send(chatId, payload) + } + + /** Respond to a permission request. + * + * @param rule - optional rule name to make the permission persistent. + * Currently the server supports `'always'`, which uses the CLI's + * permission_suggestions to produce updatedPermissions so the same + * tool call won't prompt again in this session. Omit for one-shot allow. */ + sendPermissionResponse( + chatId: string, + requestId: string, + allowed: boolean, + rule?: string, + ): boolean { + const message: Record = { + type: 'permission_response', + requestId, + allowed, + } + if (rule) message.rule = rule + return this.send(chatId, message) + } + + /** Stop the current generation. */ + sendStopGeneration(chatId: string): boolean { + return this.send(chatId, { type: 'stop_generation' }) + } + + /** Register (or replace) the handler for server messages on a specific chatId. */ + onServerMessage(chatId: string, handler: MessageHandler): void { + this.handlers.set(chatId, handler) + } + + /** Reset session for a chatId (e.g. /new command). */ + resetSession(chatId: string): void { + const session = this.sessions.get(chatId) + if (session) { + if (session.reconnectTimer) clearTimeout(session.reconnectTimer) + session.ws.close(1000, 'session reset') + this.sessions.delete(chatId) + } + this.handlers.delete(chatId) + this.handlerChains.delete(chatId) + } + + /** Has a session (connected or handler registered) for chatId. */ + hasSession(chatId: string): boolean { + return this.sessions.has(chatId) || this.handlers.has(chatId) + } + + /** Destroy all sessions. */ + destroy(): void { + this.destroyed = true + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + } + for (const [, session] of this.sessions) { + if (session.reconnectTimer) clearTimeout(session.reconnectTimer) + session.ws.close(1000, 'bridge destroyed') + } + this.sessions.clear() + this.handlers.clear() + this.handlerChains.clear() + } + + // ------- internal ------- + + private connect(chatId: string, sessionId: string): void { + const url = `${this.serverUrl}/ws/${sessionId}` + const ws = new WebSocket(url) + + // Cancel any pending reconnect timer for this chatId + const prev = this.sessions.get(chatId) + if (prev) { + if (prev.reconnectTimer) clearTimeout(prev.reconnectTimer) + prev.ws.removeAllListeners() + } + + const session: Session = { + sessionId, + ws, + reconnectAttempts: prev?.reconnectAttempts ?? 0, + reconnectTimer: null, + } + this.sessions.set(chatId, session) + + ws.on('open', () => { + console.log(`[WsBridge] Connected: ${sessionId}`) + session.reconnectAttempts = 0 + }) + + ws.on('message', (raw) => { + let msg: ServerMessage + try { + msg = JSON.parse(raw.toString()) + } catch (err) { + console.error('[WsBridge] Parse error:', err) + return + } + if (msg.type === 'pong') return + const handler = this.handlers.get(chatId) + if (!handler) return + + // Serialize per-chat handler calls: chain each message onto the previous + // one so a slow handler (e.g. one awaiting im.message.create) fully + // finishes before the next message's handler runs. This prevents state + // races where a later message reads stale map entries set up by an + // earlier-but-still-in-flight handler. + const prev = this.handlerChains.get(chatId) ?? Promise.resolve() + const next = prev + .catch(() => {}) // upstream errors must not poison the chain + .then(() => Promise.resolve().then(() => handler(msg))) + .catch((err) => { + console.error(`[WsBridge] Handler error on ${chatId}:`, err) + }) + this.handlerChains.set(chatId, next) + }) + + ws.on('close', (code, reason) => { + console.log(`[WsBridge] Disconnected: ${sessionId} (${code}: ${reason})`) + if (this.sessions.get(chatId) !== session) return + if (code === 1000) { + if (session.reconnectTimer) clearTimeout(session.reconnectTimer) + this.sessions.delete(chatId) + this.handlers.delete(chatId) + this.handlerChains.delete(chatId) + return + } + this.scheduleReconnect(chatId, sessionId) + }) + + ws.on('error', (err) => { + console.error(`[WsBridge] Error on ${sessionId}:`, err.message) + }) + } + + /** Wait until the WebSocket for chatId is open. Resolves false on timeout or error. */ + waitForOpen(chatId: string, timeoutMs = 10_000): Promise { + const session = this.sessions.get(chatId) + if (!session) return Promise.resolve(false) + if (session.ws.readyState === WebSocket.OPEN) return Promise.resolve(true) + return new Promise((resolve) => { + const timer = setTimeout(() => { + cleanup() + resolve(false) + }, timeoutMs) + const onOpen = () => { cleanup(); resolve(true) } + const onError = () => { cleanup(); resolve(false) } + const onClose = () => { cleanup(); resolve(false) } + const cleanup = () => { + clearTimeout(timer) + session.ws.removeListener('open', onOpen) + session.ws.removeListener('error', onError) + session.ws.removeListener('close', onClose) + } + session.ws.once('open', onOpen) + session.ws.once('error', onError) + session.ws.once('close', onClose) + }) + } + + private send(chatId: string, message: Record): boolean { + const session = this.sessions.get(chatId) + if (!session || session.ws.readyState !== WebSocket.OPEN) { + console.warn(`[WsBridge] Cannot send to ${chatId}: session not ready`) + return false + } + session.ws.send(JSON.stringify(message)) + return true + } + + private scheduleReconnect(chatId: string, sessionId: string): void { + if (this.destroyed) return + const session = this.sessions.get(chatId) + if (!session) return + if (session.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + console.error(`[WsBridge] Max reconnect attempts reached for ${sessionId}, giving up`) + this.sessions.delete(chatId) + this.handlers.delete(chatId) + return + } + + session.reconnectAttempts++ + const delay = Math.min( + RECONNECT_BASE_MS * Math.pow(2, session.reconnectAttempts - 1), + RECONNECT_MAX_MS, + ) + console.log(`[WsBridge] Reconnecting ${sessionId} in ${delay}ms (attempt ${session.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`) + session.reconnectTimer = setTimeout(() => { + if (this.destroyed) return + if (this.sessions.get(chatId)?.sessionId === sessionId) { + this.connect(chatId, sessionId) + } + }, delay) + } + + private startHeartbeat(): void { + this.heartbeatTimer = setInterval(() => { + for (const [, session] of this.sessions) { + if (session.ws.readyState === WebSocket.OPEN) { + session.ws.send(JSON.stringify({ type: 'ping' })) + } + } + }, HEARTBEAT_INTERVAL_MS) + } +} diff --git a/adapters/dingtalk/__tests__/ai-card.test.ts b/adapters/dingtalk/__tests__/ai-card.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..58a18248d18f516acba172b97860affc00936cb3 --- /dev/null +++ b/adapters/dingtalk/__tests__/ai-card.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { buildDeliverBody, DingTalkAiCardService } from '../ai-card.js' + +describe('DingTalk AI Card streaming', () => { + const originalFetch = globalThis.fetch + const calls: Array<{ url: string; method: string; body: any }> = [] + + beforeEach(() => { + calls.length = 0 + globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ + url: String(url), + method: init?.method ?? 'GET', + body: init?.body ? JSON.parse(String(init.body)) : null, + }) + return new Response('{}', { status: 200 }) + }) as any + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('builds the official IM_ROBOT deliver payload', () => { + expect(buildDeliverBody('card-1', { type: 'user', userId: 'staff-1' }, 'robot-1')).toMatchObject({ + outTrackId: 'card-1', + openSpaceId: 'dtv1.card//IM_ROBOT.staff-1', + imRobotOpenDeliverModel: { + spaceType: 'IM_ROBOT', + robotCode: 'robot-1', + }, + }) + }) + + it('creates, streams, and finishes an AI card', async () => { + const service = new DingTalkAiCardService(async () => 'token-1', 'robot-1') + const card = await service.createForTarget({ type: 'user', userId: 'staff-1' }) + + expect(card?.cardInstanceId.startsWith('card_')).toBe(true) + expect(calls.map((call) => `${call.method} ${new URL(call.url).pathname}`)).toEqual([ + 'POST /v1.0/card/instances', + 'POST /v1.0/card/instances/deliver', + ]) + + await service.stream(card!, 'Hello', false) + expect(calls.at(-2)?.body.cardData.cardParamMap.flowStatus).toBe('2') + expect(new URL(calls.at(-1)!.url).pathname).toBe('/v1.0/card/streaming') + expect(calls.at(-1)?.body).toMatchObject({ + key: 'msgContent', + content: 'Hello', + isFull: true, + isFinalize: false, + }) + + calls.length = 0 + await service.finish(card!, 'Final') + expect(calls.map((call) => `${call.method} ${new URL(call.url).pathname}`)).toEqual([ + 'PUT /v1.0/card/streaming', + 'PUT /v1.0/card/instances', + ]) + expect(calls[0]!.body.isFinalize).toBe(true) + expect(calls[1]!.body.cardData.cardParamMap.flowStatus).toBe('3') + }) + + it('times out a hung card streaming request', async () => { + const previousTimeout = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS + process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = '20' + globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ + url: String(url), + method: init?.method ?? 'GET', + body: init?.body ? JSON.parse(String(init.body)) : null, + }) + return await new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')) + }) + }) + }) as any + + try { + const service = new DingTalkAiCardService(async () => 'token-1', 'robot-1') + const card = { + cardInstanceId: 'card-hung', + accessToken: 'token-1', + tokenExpireTime: Date.now() + 60_000, + inputingStarted: true, + } + + await expect(service.stream(card, 'Hello', false)).rejects.toThrow( + 'PUT /v1.0/card/streaming timed out after 20ms', + ) + } finally { + if (previousTimeout === undefined) { + delete process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS + } else { + process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = previousTimeout + } + } + }) +}) diff --git a/adapters/dingtalk/__tests__/helpers.test.ts b/adapters/dingtalk/__tests__/helpers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b072da1bc00af1413eec001c342fc7812b096dad --- /dev/null +++ b/adapters/dingtalk/__tests__/helpers.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'bun:test' +import { + extractDingTalkAttachments, + extractDingTalkText, + getDingTalkChatId, + getDingTalkSenderId, + isDingTalkDirectMessage, + parseDingTalkPayload, +} from '../helpers.js' + +describe('DingTalk helpers', () => { + it('parses robot payload JSON safely', () => { + expect(parseDingTalkPayload('{"msgtype":"text"}')?.msgtype).toBe('text') + expect(parseDingTalkPayload('not-json')).toBeNull() + }) + + it('extracts sender and chat ids for direct messages', () => { + const data = { + conversationType: '1', + senderStaffId: 'staff-1', + conversationId: 'cid-1', + } + + expect(isDingTalkDirectMessage(data)).toBe(true) + expect(getDingTalkSenderId(data)).toBe('staff-1') + expect(getDingTalkChatId(data)).toBe('dingtalk:dm:staff-1') + }) + + it('extracts text from common DingTalk content shapes', () => { + expect(extractDingTalkText({ text: { content: ' hello ' } })).toBe('hello') + expect(extractDingTalkText({ content: '{"text":"from content"}' })).toBe('from content') + expect(extractDingTalkText({ + content: { + richText: [ + { text: 'hello' }, + { text: ' world' }, + ], + }, + })).toBe('hello world') + }) + + it('extracts image and file attachment candidates', () => { + expect(extractDingTalkAttachments({ + msgtype: 'picture', + content: { pictureUrl: 'https://example.com/a.jpg', downloadCode: 'pic-code' }, + })).toEqual([{ kind: 'image', url: 'https://example.com/a.jpg', downloadCode: 'pic-code' }]) + + expect(extractDingTalkAttachments({ + msgtype: 'file', + content: '{"fileName":"report.pdf","downloadCode":"file-code"}', + })).toEqual([{ kind: 'file', downloadCode: 'file-code', fileName: 'report.pdf' }]) + + expect(extractDingTalkAttachments({ + msgtype: 'richText', + content: { richText: [{ text: 'hi' }, { type: 'picture', downloadCode: 'rich-pic' }] }, + })).toEqual([{ kind: 'image', url: undefined, downloadCode: 'rich-pic' }]) + }) +}) diff --git a/adapters/dingtalk/__tests__/permission-card.test.ts b/adapters/dingtalk/__tests__/permission-card.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e33162ea51efe33bc3dcda3952f7e38f8da49e77 --- /dev/null +++ b/adapters/dingtalk/__tests__/permission-card.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'bun:test' +import { + buildDingTalkPermissionCardParams, + parseDingTalkPermissionCardAction, +} from '../permission-card.js' + +describe('DingTalk permission card helpers', () => { + it('builds template params with three permission actions', () => { + const params = buildDingTalkPermissionCardParams('Bash', { command: 'npm test' }, 'req-1') + + expect(params.requestId).toBe('req-1') + expect(params.toolName).toBe('Bash') + expect(String(params.inputPreview)).toContain('npm test') + expect(JSON.parse(String(params.allowValue))).toEqual({ action: 'permit', requestId: 'req-1', allowed: true }) + expect(JSON.parse(String(params.alwaysValue))).toEqual({ action: 'permit', requestId: 'req-1', allowed: true, rule: 'always' }) + expect(JSON.parse(String(params.denyValue))).toEqual({ action: 'permit', requestId: 'req-1', allowed: false }) + }) + + it('parses nested card private params', () => { + const action = parseDingTalkPermissionCardAction({ + outTrackId: 'permission_req-1', + content: JSON.stringify({ + cardPrivateData: { + params: { + action: 'permit', + requestId: 'req-1', + allowed: true, + rule: 'always', + }, + }, + }), + }) + + expect(action).toEqual({ requestId: 'req-1', allowed: true, rule: 'always' }) + }) + + it('parses compact callback values', () => { + expect(parseDingTalkPermissionCardAction({ actionValue: 'permit:req-2:no' })).toEqual({ + requestId: 'req-2', + allowed: false, + }) + }) + + it('ignores callbacks without permission action data', () => { + expect(parseDingTalkPermissionCardAction({ action: 'open_url', url: 'https://example.com' })).toBeNull() + }) +}) diff --git a/adapters/dingtalk/__tests__/stream-state.test.ts b/adapters/dingtalk/__tests__/stream-state.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..920096eee807dcbec927cbd1389864d2ff2b8ab1 --- /dev/null +++ b/adapters/dingtalk/__tests__/stream-state.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'bun:test' +import { MessageBuffer } from '../../common/message-buffer.js' +import { finishAndResetDingTalkStreamingState, resetDingTalkStreamingState } from '../stream-state.js' + +describe('DingTalk streaming state', () => { + it('drops the active AI card stream when permission interrupts output ordering', async () => { + let resetCalled = false + const buffer = new MessageBuffer(async () => {}, 100, 1000) + const originalReset = buffer.reset.bind(buffer) + buffer.reset = () => { + resetCalled = true + originalReset() + } + + const state = { + aiCardBuffers: new Map([['chat-1', buffer]]), + streamingCards: new Map([['chat-1', Promise.resolve(null)]]), + streamingCardText: new Map([['chat-1', 'pre-permission text']]), + } + + resetDingTalkStreamingState(state, 'chat-1') + + expect(resetCalled).toBe(true) + expect(state.aiCardBuffers.has('chat-1')).toBe(false) + expect(state.streamingCards.has('chat-1')).toBe(false) + expect(state.streamingCardText.has('chat-1')).toBe(false) + }) + + it('completes the existing stream before dropping it for a permission request', async () => { + const flushed: Array<{ text: string; complete: boolean }> = [] + let finalized = false + const buffer = new MessageBuffer( + async (text, complete) => { + flushed.push({ text, complete }) + }, + 100, + 1000, + ) + buffer.append('pre-permission text') + + const state = { + aiCardBuffers: new Map([['chat-1', buffer]]), + streamingCards: new Map([['chat-1', Promise.resolve(null)]]), + streamingCardText: new Map([['chat-1', 'already streamed']]), + finalize: async () => { + finalized = true + }, + } + + await finishAndResetDingTalkStreamingState(state, 'chat-1') + + expect(flushed).toEqual([{ text: 'pre-permission text', complete: true }]) + expect(finalized).toBe(true) + expect(state.aiCardBuffers.has('chat-1')).toBe(false) + expect(state.streamingCards.has('chat-1')).toBe(false) + expect(state.streamingCardText.has('chat-1')).toBe(false) + }) + + it('finalizes an already-flushed card even when the message buffer is empty', async () => { + let finalized = false + const buffer = new MessageBuffer(async () => {}, 100, 1000) + const state = { + aiCardBuffers: new Map([['chat-1', buffer]]), + streamingCards: new Map([['chat-1', Promise.resolve(null)]]), + streamingCardText: new Map([['chat-1', 'already streamed']]), + finalize: async () => { + finalized = true + }, + } + + await finishAndResetDingTalkStreamingState(state, 'chat-1') + + expect(finalized).toBe(true) + expect(state.aiCardBuffers.has('chat-1')).toBe(false) + expect(state.streamingCards.has('chat-1')).toBe(false) + expect(state.streamingCardText.has('chat-1')).toBe(false) + }) +}) diff --git a/adapters/dingtalk/ai-card.ts b/adapters/dingtalk/ai-card.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a9d3fb36460af3a1818bf16b531ce36cef7e294 --- /dev/null +++ b/adapters/dingtalk/ai-card.ts @@ -0,0 +1,305 @@ +const DINGTALK_API = 'https://api.dingtalk.com' +const AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema' +const CARD_API_MAX_QPS = 20 +const QPS_BACKOFF_DURATION_MS = 2_000 +const DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS = 15_000 + +const AICardStatus = { + INPUTING: '2', + FINISHED: '3', +} as const +type AICardFlowStatus = (typeof AICardStatus)[keyof typeof AICardStatus] + +export type DingTalkAiCardTarget = + | { type: 'user'; userId: string } + | { type: 'group'; openConversationId: string } + +export type DingTalkAiCardInstance = { + cardInstanceId: string + accessToken: string + tokenExpireTime: number + inputingStarted: boolean +} + +export type DingTalkCreateCardOptions = { + cardTemplateId?: string + outTrackId?: string + cardParamMap?: Record + callbackRouteKey?: string +} + +type TokenProvider = () => Promise + +export class DingTalkAiCardService { + constructor( + private readonly getAccessToken: TokenProvider, + private readonly robotCode: string, + ) {} + + async createForTarget( + target: DingTalkAiCardTarget, + options: DingTalkCreateCardOptions = {}, + ): Promise { + try { + const token = await this.getAccessToken() + const cardInstanceId = options.outTrackId ?? `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` + const createBody: Record = { + cardTemplateId: options.cardTemplateId || AI_CARD_TEMPLATE_ID, + outTrackId: cardInstanceId, + cardData: { + cardParamMap: { + config: JSON.stringify({ autoLayout: true }), + ...options.cardParamMap, + }, + }, + callbackType: 'STREAM', + imGroupOpenSpaceModel: { supportForward: true }, + imRobotOpenSpaceModel: { supportForward: true }, + } + if (options.callbackRouteKey) createBody.callbackRouteKey = options.callbackRouteKey + await postJson('/v1.0/card/instances', token, createBody) + + await postJson('/v1.0/card/instances/deliver', token, buildDeliverBody(cardInstanceId, target, this.robotCode)) + + return { + cardInstanceId, + accessToken: token, + tokenExpireTime: Date.now() + 2 * 60 * 60 * 1000, + inputingStarted: false, + } + } catch (err) { + console.warn('[DingTalk][AICard] create failed:', err instanceof Error ? err.message : err) + return null + } + } + + async stream(card: DingTalkAiCardInstance, content: string, finished = false): Promise { + await this.ensureValidToken(card) + + if (!card.inputingStarted) { + await this.updateStatus(card, AICardStatus.INPUTING, content) + card.inputingStarted = true + } + + await withCardRateLimit(() => + putJson('/v1.0/card/streaming', card.accessToken, { + outTrackId: card.cardInstanceId, + guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + key: 'msgContent', + content: ensureTableBlankLines(content), + isFull: true, + isFinalize: finished, + isError: false, + }), + ) + } + + async finish(card: DingTalkAiCardInstance, content: string): Promise { + await this.stream(card, content, true) + try { + await this.updateStatus(card, AICardStatus.FINISHED, ensureTableBlankLines(content)) + } catch (err) { + console.warn('[DingTalk][AICard] finish status failed:', err instanceof Error ? err.message : err) + } + } + + private async updateStatus( + card: DingTalkAiCardInstance, + flowStatus: AICardFlowStatus, + content: string, + ): Promise { + const body: Record = { + outTrackId: card.cardInstanceId, + cardData: { + cardParamMap: { + flowStatus, + msgContent: ensureTableBlankLines(content), + staticMsgContent: '', + sys_full_json_obj: JSON.stringify({ order: ['msgContent'] }), + config: JSON.stringify({ autoLayout: true }), + }, + }, + } + if (flowStatus === AICardStatus.FINISHED) { + body.cardUpdateOptions = { updateCardDataByKey: true } + } + + await withCardRateLimit(() => + putJson('/v1.0/card/instances', card.accessToken, body), + ) + } + + private async ensureValidToken(card: DingTalkAiCardInstance): Promise { + if (Date.now() <= card.tokenExpireTime - 5 * 60 * 1000) return + card.accessToken = await this.getAccessToken() + card.tokenExpireTime = Date.now() + 2 * 60 * 60 * 1000 + } +} + +export function buildDeliverBody( + cardInstanceId: string, + target: DingTalkAiCardTarget, + robotCode: string, +): Record { + const base = { outTrackId: cardInstanceId, userIdType: 1 } + if (target.type === 'group') { + return { + ...base, + openSpaceId: `dtv1.card//IM_GROUP.${target.openConversationId}`, + imGroupOpenDeliverModel: { + robotCode, + }, + } + } + + return { + ...base, + openSpaceId: `dtv1.card//IM_ROBOT.${target.userId}`, + imRobotOpenDeliverModel: { + spaceType: 'IM_ROBOT', + robotCode, + extension: { + dynamicSummary: 'true', + }, + }, + } +} + +async function postJson(path: string, token: string, body: Record): Promise { + await requestJson('POST', path, token, body) +} + +async function putJson(path: string, token: string, body: Record): Promise { + await requestJson('PUT', path, token, body) +} + +async function requestJson( + method: 'POST' | 'PUT', + path: string, + token: string, + body: Record, +): Promise { + const controller = new AbortController() + const timeoutMs = getImCardRequestTimeoutMs() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const res = await fetch(`${DINGTALK_API}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'x-acs-dingtalk-access-token': token, + }, + body: JSON.stringify(body), + signal: controller.signal, + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + const err = new Error(`${method} ${path} failed: ${res.status} ${text}`) + ;(err as any).status = res.status + ;(err as any).body = text + throw err + } + } catch (err) { + if ((err as Error)?.name === 'AbortError') { + throw new Error(`${method} ${path} timed out after ${timeoutMs}ms`) + } + throw err + } finally { + clearTimeout(timer) + } +} + +function getImCardRequestTimeoutMs(): number { + const raw = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS + const parsed = raw ? Number(raw) : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS +} + +async function withCardRateLimit(fn: () => Promise): Promise { + await cardRateLimiter.waitForToken() + try { + await fn() + } catch (err) { + if (!isQpsLimitError(err)) throw err + cardRateLimiter.triggerBackoff() + await cardRateLimiter.waitForToken() + await fn() + } +} + +function isQpsLimitError(err: unknown): boolean { + return (err as any)?.status === 403 && String((err as any)?.body ?? '').includes('QpsLimit') +} + +const cardRateLimiter = { + tokens: CARD_API_MAX_QPS, + lastRefillTime: Date.now(), + backoffUntil: 0, + queueTail: Promise.resolve() as Promise, + + refill(): void { + const now = Date.now() + const elapsedSeconds = (now - this.lastRefillTime) / 1000 + if (elapsedSeconds <= 0) return + this.tokens = Math.min(CARD_API_MAX_QPS, this.tokens + elapsedSeconds * CARD_API_MAX_QPS) + this.lastRefillTime = now + }, + + async waitForToken(): Promise { + const prev = this.queueTail + let release!: () => void + this.queueTail = new Promise((resolve) => { + release = resolve + }) + try { + await prev.catch(() => {}) + const now = Date.now() + if (now < this.backoffUntil) await sleep(this.backoffUntil - now) + this.refill() + if (this.tokens < 1) { + await sleep(Math.ceil(((1 - this.tokens) / CARD_API_MAX_QPS) * 1000)) + this.refill() + } + this.tokens -= 1 + } finally { + release() + } + }, + + triggerBackoff(): void { + const backoffEnd = Date.now() + QPS_BACKOFF_DURATION_MS + this.backoffUntil = backoffEnd + this.tokens = 0 + this.lastRefillTime = backoffEnd + }, +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function ensureTableBlankLines(text: string): string { + const lines = text.split('\n') + const result: string[] = [] + const tableDividerRegex = /^\s*\|?\s*:?-+:?\s*(\|?\s*:?-+:?\s*)+\|?\s*$/ + const tableRowRegex = /^\s*\|?.*\|.*\|?\s*$/ + + for (let i = 0; i < lines.length; i++) { + const currentLine = lines[i] ?? '' + const nextLine = lines[i + 1] ?? '' + if ( + tableRowRegex.test(currentLine) && + nextLine.includes('|') && + tableDividerRegex.test(nextLine) && + i > 0 && + lines[i - 1]?.trim() !== '' && + !tableRowRegex.test(lines[i - 1] ?? '') + ) { + result.push('') + } + result.push(currentLine) + } + return result.join('\n') +} diff --git a/adapters/dingtalk/helpers.ts b/adapters/dingtalk/helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..a88592ed32cb2037b0b8a2e8fe708c7d453c0bb3 --- /dev/null +++ b/adapters/dingtalk/helpers.ts @@ -0,0 +1,113 @@ +export type DingTalkRobotMessage = { + msgId?: string + msgtype?: string + conversationType?: string + conversationId?: string + conversationTitle?: string + senderStaffId?: string + senderId?: string + senderNick?: string + sessionWebhook?: string + text?: { content?: string } + markdown?: { text?: string; title?: string } + content?: unknown +} + +export type DingTalkAttachmentCandidate = { + kind: 'image' | 'file' + url?: string + downloadCode?: string + fileName?: string +} + +export function parseDingTalkPayload(raw: unknown): DingTalkRobotMessage | null { + if (!raw) return null + if (typeof raw === 'object') return raw as DingTalkRobotMessage + if (typeof raw !== 'string') return null + try { + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed as DingTalkRobotMessage : null + } catch { + return null + } +} +export function isDingTalkDirectMessage(data: DingTalkRobotMessage): boolean { + return data.conversationType === '1' +} + +export function getDingTalkSenderId(data: DingTalkRobotMessage): string | null { + const senderId = data.senderStaffId || data.senderId + return senderId ? String(senderId) : null +} + +export function getDingTalkChatId(data: DingTalkRobotMessage): string | null { + const senderId = getDingTalkSenderId(data) + if (isDingTalkDirectMessage(data)) { + return senderId ? `dingtalk:dm:${senderId}` : null + } + return data.conversationId ? `dingtalk:group:${data.conversationId}` : null +} + +export function extractDingTalkText(data: DingTalkRobotMessage): string { + if (typeof data.text?.content === 'string') return data.text.content.trim() + if (typeof data.markdown?.text === 'string') return data.markdown.text.trim() + + const content = resolveContentObject(data.content) + if (typeof content?.text === 'string') return content.text.trim() + if (Array.isArray(content?.richText)) { + return content.richText + .map((item: unknown) => { + if (!item || typeof item !== 'object') return '' + const text = (item as { text?: unknown }).text + return typeof text === 'string' ? text : '' + }) + .join('') + .trim() + } + + return '' +} + +export function extractDingTalkAttachments(data: DingTalkRobotMessage): DingTalkAttachmentCandidate[] { + const content = resolveContentObject(data.content) + const candidates: DingTalkAttachmentCandidate[] = [] + + if (data.msgtype === 'picture') { + const url = stringValue(content?.pictureUrl) + const downloadCode = stringValue(content?.downloadCode) + if (url || downloadCode) candidates.push({ kind: 'image', url, downloadCode }) + } else if (data.msgtype === 'file') { + const downloadCode = stringValue(content?.downloadCode) + const fileName = stringValue(content?.fileName) || 'dingtalk-file' + if (downloadCode) candidates.push({ kind: 'file', downloadCode, fileName }) + } else if (data.msgtype === 'richText') { + const richText = Array.isArray(content?.richText) ? content.richText : [] + for (const item of richText) { + if (!item || typeof item !== 'object') continue + const record = item as Record + const pictureUrl = stringValue(record.pictureUrl) + const downloadCode = stringValue(record.downloadCode) + if (pictureUrl || downloadCode) { + candidates.push({ kind: 'image', url: pictureUrl, downloadCode }) + } + } + } + + return candidates +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function resolveContentObject(raw: unknown): Record | null { + if (!raw) return null + if (typeof raw === 'object') return raw as Record + if (typeof raw !== 'string') return null + try { + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed as Record : null + } catch { + return null + } +} diff --git a/adapters/dingtalk/index.ts b/adapters/dingtalk/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b2bce320307305d385fca9bcd13979f4102c52b --- /dev/null +++ b/adapters/dingtalk/index.ts @@ -0,0 +1,713 @@ +/** + * DingTalk Adapter for Claude Code Desktop. + * + * Uses DingTalk Stream to receive bot messages without a public webhook. + * The desktop Settings page stores clientId/clientSecret via QR registration. + */ + +import path from 'node:path' +import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from 'dingtalk-stream' +import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-bridge.js' +import { MessageDedup } from '../common/message-dedup.js' +import { MessageBuffer } from '../common/message-buffer.js' +import { enqueue } from '../common/chat-queue.js' +import { getConfiguredWorkDir, loadConfig } from '../common/config.js' +import { formatImHelp, formatImStatus, formatPermissionRequest, splitMessage } from '../common/format.js' +import { + formatPermissionDecisionStatus, + formatPermissionInstructions, + parsePermissionCommand, + type PermissionDecision, +} from '../common/permission.js' +import { SessionStore } from '../common/session-store.js' +import { AdapterHttpClient, type RecentProject } from '../common/http-client.js' +import { isAllowedUser, tryPair } from '../common/pairing.js' +import { AttachmentStore } from '../common/attachment/attachment-store.js' +import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js' +import { + extractDingTalkAttachments, + extractDingTalkText, + getDingTalkChatId, + getDingTalkSenderId, + isDingTalkDirectMessage, + parseDingTalkPayload, + type DingTalkRobotMessage, +} from './helpers.js' +import { DingTalkMediaService } from './media.js' +import { + DingTalkAiCardService, + type DingTalkAiCardInstance, + type DingTalkAiCardTarget, +} from './ai-card.js' +import { + buildDingTalkPermissionCardParams, + DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE, + parseDingTalkPermissionCardAction, +} from './permission-card.js' +import { finishAndResetDingTalkStreamingState, resetDingTalkStreamingState } from './stream-state.js' + +const DINGTALK_API = 'https://api.dingtalk.com' + +const config = loadConfig() +if (!config.dingtalk.clientId || !config.dingtalk.clientSecret) { + console.error('[DingTalk] Missing DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET. Bind with QR auth in Desktop Settings or set env.') + process.exit(1) +} +const defaultWorkDir = getConfiguredWorkDir(config, config.dingtalk) + +const bridge = new WsBridge(config.serverUrl, 'dingtalk') +const dedup = new MessageDedup() +const sessionStore = new SessionStore() +const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] }) +const attachmentStore = new AttachmentStore() +const media = new DingTalkMediaService(attachmentStore) +const aiCards = new DingTalkAiCardService(getAccessToken, config.dingtalk.clientId) +const sessionWebhooks = new Map() +const pendingProjectSelection = new Map() +const runtimeStates = new Map() +const aiCardBuffers = new Map() +const aiCardTargets = new Map() +const streamingCards = new Map>() +const streamingCardText = new Map() +const pendingPermissions = new Map>() +const pendingPermissionChats = new Map() + +let accessTokenCache: { token: string; expiresAt: number } | null = null + +attachmentStore.gc().catch((err) => { + console.warn('[DingTalk] AttachmentStore.gc failed:', err instanceof Error ? err.message : err) +}) + +type ChatRuntimeState = { + state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending' + verb?: string + model?: string + pendingPermissionCount: number +} + +function getRuntimeState(chatId: string): ChatRuntimeState { + let state = runtimeStates.get(chatId) + if (!state) { + state = { state: 'idle', pendingPermissionCount: 0 } + runtimeStates.set(chatId, state) + } + return state +} + +async function getAccessToken(): Promise { + const now = Date.now() + if (accessTokenCache && accessTokenCache.expiresAt > now + 60_000) { + return accessTokenCache.token + } + + const res = await fetch(`${DINGTALK_API}/v1.0/oauth2/accessToken`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + appKey: config.dingtalk.clientId, + appSecret: config.dingtalk.clientSecret, + }), + }) + const data = await res.json().catch(() => null) as { accessToken?: string; expireIn?: number; message?: string } | null + if (!res.ok || !data?.accessToken) { + throw new Error(data?.message || `accessToken request failed: ${res.status}`) + } + + accessTokenCache = { + token: data.accessToken, + expiresAt: now + Number(data.expireIn ?? 7200) * 1000, + } + return data.accessToken +} + +async function sendText(chatId: string, text: string): Promise { + const sessionWebhook = sessionWebhooks.get(chatId) + if (!sessionWebhook) { + console.warn(`[DingTalk] Missing sessionWebhook for ${chatId}; cannot send response`) + return + } + + const token = await getAccessToken() + for (const chunk of splitMessage(text, 3500)) { + const res = await fetch(sessionWebhook, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-acs-dingtalk-access-token': token, + }, + body: JSON.stringify({ + msgtype: 'markdown', + markdown: { + title: 'Claude Code', + text: chunk, + }, + }), + }) + if (!res.ok) { + const body = await res.text().catch(() => '') + console.warn(`[DingTalk] sendText failed: ${res.status} ${body}`) + } + } +} + +function getAiCardBuffer(chatId: string): MessageBuffer { + let buffer = aiCardBuffers.get(chatId) + if (!buffer) { + buffer = new MessageBuffer( + async (text, isComplete) => flushToAiCard(chatId, text, isComplete), + 1200, + 200, + ) + aiCardBuffers.set(chatId, buffer) + } + return buffer +} + +function getOrCreateAiCard(chatId: string): Promise | null { + const target = aiCardTargets.get(chatId) + if (!target) return null + + let card = streamingCards.get(chatId) + if (!card) { + card = aiCards.createForTarget(target) + streamingCards.set(chatId, card) + } + return card +} + +async function flushToAiCard(chatId: string, newText: string, isComplete: boolean): Promise { + const fullText = (streamingCardText.get(chatId) ?? '') + newText + streamingCardText.set(chatId, fullText) + if (!fullText.trim()) return + + const cardPromise = getOrCreateAiCard(chatId) + const card = cardPromise ? await cardPromise : null + if (!card) { + if (isComplete) await sendText(chatId, fullText) + return + } + + try { + if (isComplete) { + await aiCards.finish(card, fullText) + streamingCards.delete(chatId) + streamingCardText.delete(chatId) + aiCardBuffers.get(chatId)?.reset() + aiCardBuffers.delete(chatId) + } else { + await aiCards.stream(card, `${fullText} ▍`, false) + } + } catch (err) { + console.warn('[DingTalk][AICard] stream failed, falling back to markdown:', err instanceof Error ? err.message : err) + streamingCards.delete(chatId) + if (isComplete) await sendText(chatId, fullText) + } +} + +function clearTransientChatState(chatId: string): void { + resetDingTalkStreamingState({ aiCardBuffers, streamingCards, streamingCardText }, chatId) + clearPendingPermissions(chatId) + const runtime = getRuntimeState(chatId) + runtime.state = 'idle' + runtime.verb = undefined + runtime.pendingPermissionCount = 0 +} + +function clearPendingPermissions(chatId: string): void { + const pending = pendingPermissions.get(chatId) + if (pending) { + for (const requestId of pending) pendingPermissionChats.delete(requestId) + } + pendingPermissions.delete(chatId) +} + +async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { + const stored = sessionStore.get(chatId) + if (!stored) return null + + if (!bridge.hasSession(chatId)) { + bridge.connectSession(chatId, stored.sessionId) + bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) + const opened = await bridge.waitForOpen(chatId) + if (!opened) return null + } + + return stored +} + +async function buildStatusText(chatId: string): Promise { + const stored = await ensureExistingSession(chatId) + if (!stored) return formatImStatus(null) + + const runtime = getRuntimeState(chatId) + let projectName = path.basename(stored.workDir) || stored.workDir + let branch: string | null = null + + try { + const gitInfo = await httpClient.getGitInfo(stored.sessionId) + projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName + branch = gitInfo.branch + } catch { + // Status should still be useful when git lookup fails. + } + + let taskCounts: + | { + total: number + pending: number + inProgress: number + completed: number + } + | undefined + + try { + const tasks = await httpClient.getTasksForSession(stored.sessionId) + if (tasks.length > 0) { + taskCounts = { + total: tasks.length, + pending: tasks.filter((task) => task.status === 'pending').length, + inProgress: tasks.filter((task) => task.status === 'in_progress').length, + completed: tasks.filter((task) => task.status === 'completed').length, + } + } + } catch { + // Ignore task lookup failures. + } + + return formatImStatus({ + sessionId: stored.sessionId, + projectName, + branch, + model: runtime.model, + state: runtime.state, + verb: runtime.verb, + pendingPermissionCount: runtime.pendingPermissionCount, + taskCounts, + }) +} + +async function ensureSession(chatId: string): Promise { + if (bridge.hasSession(chatId)) return true + + const stored = sessionStore.get(chatId) + if (stored) { + bridge.connectSession(chatId, stored.sessionId) + bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) + return await bridge.waitForOpen(chatId) + } + + return await createSessionForChat(chatId, defaultWorkDir) +} + +async function createSessionForChat(chatId: string, workDir: string): Promise { + try { + bridge.resetSession(chatId) + clearTransientChatState(chatId) + + const sessionId = await httpClient.createSession(workDir) + sessionStore.set(chatId, sessionId, workDir) + bridge.connectSession(chatId, sessionId) + bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) + const opened = await bridge.waitForOpen(chatId) + if (!opened) { + await sendText(chatId, '⚠️ 连接服务器超时,请重试。') + return false + } + return true + } catch (err) { + await sendText(chatId, `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`) + return false + } +} + +function formatProjectList(projects: RecentProject[]): string { + const lines = projects.slice(0, 10).map((project, index) => { + const branch = project.branch ? ` (${project.branch})` : '' + return `${index + 1}. **${project.projectName}**${branch}\n ${project.realPath}` + }) + return `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n也可以发送 /new <编号或名称>` +} + +async function showProjectPicker(chatId: string): Promise { + try { + const projects = await httpClient.listRecentProjects() + if (projects.length === 0) { + await sendText(chatId, `没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`) + return + } + pendingProjectSelection.set(chatId, true) + await sendText(chatId, formatProjectList(projects)) + } catch (err) { + await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`) + } +} + +async function startNewSession(chatId: string, query?: string): Promise { + bridge.resetSession(chatId) + sessionStore.delete(chatId) + clearTransientChatState(chatId) + pendingProjectSelection.delete(chatId) + runtimeStates.delete(chatId) + + if (query) { + try { + const { project, ambiguous } = await httpClient.matchProject(query) + if (project) { + const ok = await createSessionForChat(chatId, project.realPath) + if (ok) await sendText(chatId, `✅ 已新建会话:**${project.projectName}**${project.branch ? ` (${project.branch})` : ''}`) + return + } + if (ambiguous) { + const list = ambiguous.map((project, index) => `${index + 1}. **${project.projectName}** — ${project.realPath}`).join('\n') + await sendText(chatId, `匹配到多个项目,请更精确:\n\n${list}`) + return + } + await sendText(chatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`) + } catch (err) { + await sendText(chatId, `❌ ${err instanceof Error ? err.message : String(err)}`) + } + return + } + + const ok = await createSessionForChat(chatId, defaultWorkDir) + if (ok) await sendText(chatId, '✅ 已新建会话,可以开始对话了。') +} + +async function handleServerMessage(chatId: string, msg: ServerMessage): Promise { + const runtime = getRuntimeState(chatId) + + switch (msg.type) { + case 'connected': + break + case 'status': + runtime.state = msg.state + runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined + break + case 'content_start': + if (msg.blockType === 'text') { + runtime.state = 'streaming' + } + if (msg.blockType === 'tool_use') runtime.state = 'tool_executing' + break + case 'content_delta': + if (typeof msg.text === 'string' && msg.text) getAiCardBuffer(chatId).append(msg.text) + break + case 'tool_use_complete': + runtime.state = 'streaming' + break + case 'permission_request': { + await sendPermissionRequest(chatId, msg) + break + } + case 'message_complete': + runtime.state = 'idle' + runtime.verb = undefined + await finishAndResetDingTalkStreamingState({ aiCardBuffers, streamingCards, streamingCardText, finalize: () => flushToAiCard(chatId, '', true) }, chatId) + break + case 'error': + runtime.state = 'idle' + runtime.verb = undefined + aiCardBuffers.get(chatId)?.reset() + streamingCards.delete(chatId) + streamingCardText.delete(chatId) + await sendText(chatId, `❌ ${msg.message}`) + break + case 'system_notification': + if (msg.subtype === 'init' && msg.data && typeof msg.data === 'object') { + const model = (msg.data as Record).model + if (typeof model === 'string' && model.trim()) runtime.model = model + } + break + } +} + +async function sendPermissionRequest(chatId: string, msg: ServerMessage): Promise { + const runtime = getRuntimeState(chatId) + runtime.pendingPermissionCount += 1 + runtime.state = 'permission_pending' + await finishAndResetDingTalkStreamingState({ aiCardBuffers, streamingCards, streamingCardText, finalize: () => flushToAiCard(chatId, '', true) }, chatId) + + const set = pendingPermissions.get(chatId) ?? new Set() + set.add(msg.requestId) + pendingPermissions.set(chatId, set) + pendingPermissionChats.set(msg.requestId, chatId) + + const requestText = formatPermissionRequest(msg.toolName, msg.input, msg.requestId) + const instructions = formatPermissionInstructions(msg.requestId) + const templateId = config.dingtalk.permissionCardTemplateId.trim() + const target = aiCardTargets.get(chatId) + + if (templateId && target) { + const card = await aiCards.createForTarget(target, { + cardTemplateId: templateId, + outTrackId: `permission_${msg.requestId}`, + callbackRouteKey: DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE, + cardParamMap: buildDingTalkPermissionCardParams(msg.toolName, msg.input, msg.requestId), + }) + if (card) { + await sendText(chatId, `${requestText}\n\n已发送钉钉权限卡片;如果卡片不可见,也可以${instructions}`) + return + } + } + + await sendText(chatId, `${requestText}\n\n${instructions}`) +} + +function handlePermissionCommand(chatId: string, text: string): boolean { + const decision = parsePermissionCommand(text, pendingPermissions.get(chatId)) + if (!decision) return false + + const sent = applyPermissionDecision(chatId, decision) + if (!sent) return true + + void sendText(chatId, formatPermissionDecisionStatus(decision)) + return true +} + +function applyPermissionDecision(chatId: string, decision: PermissionDecision): boolean { + const { requestId, allowed, rule } = decision + const pending = pendingPermissions.get(chatId) + if (!pending?.has(requestId)) { + void sendText(chatId, `未找到待确认的权限请求:${requestId}`) + return false + } + + const sent = bridge.sendPermissionResponse(chatId, requestId, allowed, rule) + if (!sent) { + void sendText(chatId, '权限响应发送失败,请检查会话状态。') + return false + } + + pending.delete(requestId) + pendingPermissionChats.delete(requestId) + const runtime = getRuntimeState(chatId) + runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1) + return sent +} + +async function routeUserMessage(chatId: string, text: string, attachments: AttachmentRef[] = []): Promise { + enqueue(chatId, async () => { + const trimmed = text.trim() + const hasAttachments = attachments.length > 0 + + if (!hasAttachments && handlePermissionCommand(chatId, trimmed)) return + + if (!hasAttachments && pendingProjectSelection.has(chatId)) { + if (trimmed) await startNewSession(chatId, trimmed) + return + } + + if (!hasAttachments && (trimmed === '/new' || trimmed === '新会话' || trimmed.startsWith('/new '))) { + const arg = trimmed.startsWith('/new ') ? trimmed.slice(5).trim() : '' + await startNewSession(chatId, arg || undefined) + return + } + if (!hasAttachments && (trimmed === '/help' || trimmed === '帮助')) { + await sendText(chatId, formatImHelp()) + return + } + if (!hasAttachments && (trimmed === '/status' || trimmed === '状态')) { + await sendText(chatId, await buildStatusText(chatId)) + return + } + if (!hasAttachments && (trimmed === '/clear' || trimmed === '清空')) { + const stored = await ensureExistingSession(chatId) + if (!stored) { + await sendText(chatId, formatImStatus(null)) + return + } + clearTransientChatState(chatId) + if (!bridge.sendUserMessage(chatId, '/clear')) { + await sendText(chatId, '⚠️ 无法发送 /clear,请先发送 /new 重新连接会话。') + return + } + await sendText(chatId, '🧹 已清空当前会话上下文。') + return + } + if (!hasAttachments && (trimmed === '/stop' || trimmed === '停止')) { + const stored = await ensureExistingSession(chatId) + if (!stored) { + await sendText(chatId, formatImStatus(null)) + return + } + bridge.sendStopGeneration(chatId) + await sendText(chatId, '⏹ 已发送停止信号。') + return + } + if (!hasAttachments && (trimmed === '/projects' || trimmed === '项目列表')) { + await showProjectPicker(chatId) + return + } + + const ready = await ensureSession(chatId) + if (!ready) return + const effectiveText = trimmed || (attachments.length > 0 ? '(用户发送了附件)' : '') + if (!effectiveText && attachments.length === 0) return + if (!bridge.sendUserMessage(chatId, effectiveText, attachments.length ? attachments : undefined)) { + await sendText(chatId, '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。') + } + }) +} + +async function handleRobotMessage(data: DingTalkRobotMessage): Promise { + if (!isDingTalkDirectMessage(data)) return + + const chatId = getDingTalkChatId(data) + const userId = getDingTalkSenderId(data) + const text = extractDingTalkText(data) + const mediaCandidates = extractDingTalkAttachments(data) + if (!chatId || !userId || (!text && mediaCandidates.length === 0)) return + + if (data.sessionWebhook) sessionWebhooks.set(chatId, data.sessionWebhook) + + if (!isAllowedUser('dingtalk', userId)) { + const success = tryPair(text, { userId, displayName: data.senderNick || 'DingTalk User' }, 'dingtalk') + await sendText( + chatId, + success + ? '✅ 配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。发送 /help 查看可用命令。' + : '🔒 未授权。请先在 Claude Code 桌面端完成钉钉扫码绑定,再生成 IM 配对码后发送给我。', + ) + return + } + + aiCardTargets.set(chatId, { type: 'user', userId }) + const attachments = await collectAttachments(chatId, mediaCandidates) + await routeUserMessage(chatId, text, attachments) +} + +async function handleCardCallback(raw: unknown): Promise { + const action = parseDingTalkPermissionCardAction(raw) + if (!action) return + + const chatId = action.chatId && pendingPermissions.has(action.chatId) + ? action.chatId + : pendingPermissionChats.get(action.requestId) + if (!chatId) { + console.warn(`[DingTalk][Card] permission request not found: ${action.requestId}`) + return + } + + if (applyPermissionDecision(chatId, action)) { + await sendText(chatId, formatPermissionDecisionStatus(action)) + } +} + +async function collectAttachments( + chatId: string, + candidates: ReturnType, +): Promise { + if (candidates.length === 0) return [] + const stored = sessionStore.get(chatId) + const sessionId = stored?.sessionId ?? chatId + let token: string + try { + token = await getAccessToken() + } catch (err) { + console.error('[DingTalk] access token for attachment download failed:', err) + await sendText(chatId, '📎 附件下载授权失败,请稍后重试。') + return [] + } + + const settled = await Promise.allSettled( + candidates.map((candidate) => + media.downloadCandidate(candidate, sessionId, { + clientId: config.dingtalk.clientId, + accessToken: token, + }), + ), + ) + const attachments: AttachmentRef[] = [] + let failures = 0 + for (const result of settled) { + if (result.status === 'rejected') { + failures += 1 + console.error('[DingTalk] media download failed:', result.reason) + continue + } + const local = result.value + const check = checkAttachmentLimit(local.kind, local.size, local.mimeType) + if (!check.ok) { + await sendText(chatId, check.hint) + continue + } + if (local.kind === 'image') { + attachments.push({ + type: 'image', + name: local.name, + data: local.buffer.toString('base64'), + mimeType: local.mimeType, + }) + } else { + attachments.push({ + type: 'file', + name: local.name, + path: local.path, + mimeType: local.mimeType, + }) + } + } + if (failures > 0) { + await sendText( + chatId, + failures === candidates.length ? '📎 附件下载失败,请稍后重试。' : `📎 ${failures} 个附件下载失败,已跳过。`, + ) + } + return attachments +} + +async function start(): Promise { + const client = new DWClient({ + clientId: config.dingtalk.clientId, + clientSecret: config.dingtalk.clientSecret, + endpoint: config.dingtalk.endpoint, + autoReconnect: true, + keepAlive: true, + } as any) + + client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => { + const messageId = res.headers?.messageId + if (messageId) { + client.socketCallBackResponse(messageId, { success: true }) + if (!dedup.tryRecord(`header:${messageId}`)) return + } + + const data = parseDingTalkPayload(res.data) + if (!data) return + if (data.msgId && !dedup.tryRecord(`body:${data.msgId}`)) return + + await handleRobotMessage(data) + }) + + client.registerCallbackListener(TOPIC_CARD, async (res: any) => { + const messageId = res.headers?.messageId + if (messageId) { + client.socketCallBackResponse(messageId, { success: true }) + if (!dedup.tryRecord(`card:${messageId}`)) return + } + + await handleCardCallback(res.data ?? res) + }) + + await client.connect() + console.log(`[DingTalk] Stream connected. Server: ${config.serverUrl}`) + + const shutdown = async () => { + console.log('[DingTalk] Shutting down...') + bridge.destroy() + dedup.destroy() + try { + await client.disconnect() + } catch { + // ignore + } + process.exit(0) + } + process.once('SIGINT', () => void shutdown()) + process.once('SIGTERM', () => void shutdown()) +} + +start().catch((err) => { + console.error('[DingTalk] Fatal:', err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/adapters/dingtalk/media.ts b/adapters/dingtalk/media.ts new file mode 100644 index 0000000000000000000000000000000000000000..33225326ef6b9b32e56ea0a40a6102d38193b29b --- /dev/null +++ b/adapters/dingtalk/media.ts @@ -0,0 +1,84 @@ +import path from 'node:path' +import { AttachmentStore } from '../common/attachment/attachment-store.js' +import type { LocalAttachment } from '../common/attachment/attachment-types.js' +import type { DingTalkAttachmentCandidate } from './helpers.js' + +const DINGTALK_API = 'https://api.dingtalk.com' + +export class DingTalkMediaService { + constructor(private readonly store: AttachmentStore) {} + + async downloadCandidate( + candidate: DingTalkAttachmentCandidate, + sessionId: string, + opts: { clientId: string; accessToken: string }, + ): Promise { + const downloadUrl = candidate.url || await this.resolveDownloadUrl(candidate.downloadCode, opts) + if (!downloadUrl) throw new Error('DingTalk media item is missing a download URL') + + const resp = await fetch(downloadUrl) + if (!resp.ok) { + throw new Error(`DingTalk media download failed: ${resp.status} ${resp.statusText}`) + } + const buffer = Buffer.from(await resp.arrayBuffer()) + const contentType = resp.headers.get('content-type') || inferMime(candidate.fileName, candidate.kind) + const name = candidate.fileName || buildImageName(contentType) + const target = this.store.resolvePath('dingtalk', sessionId, name) + const savedPath = await this.store.write(target, buffer) + + return { + kind: candidate.kind, + name, + path: savedPath, + buffer, + size: buffer.length, + mimeType: contentType, + } + } + + private async resolveDownloadUrl( + downloadCode: string | undefined, + opts: { clientId: string; accessToken: string }, + ): Promise { + if (!downloadCode) return null + const resp = await fetch(`${DINGTALK_API}/v1.0/robot/messageFiles/download`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-acs-dingtalk-access-token': opts.accessToken, + }, + body: JSON.stringify({ + downloadCode, + robotCode: opts.clientId, + }), + }) + const body = await resp.json().catch(() => null) as { downloadUrl?: string; message?: string } | null + if (!resp.ok || !body?.downloadUrl) { + throw new Error(body?.message || `DingTalk downloadCode exchange failed: ${resp.status}`) + } + return body.downloadUrl + } +} + +function buildImageName(mime?: string): string { + const ext = mime?.includes('png') + ? '.png' + : mime?.includes('gif') + ? '.gif' + : mime?.includes('webp') + ? '.webp' + : '.jpg' + return `dingtalk-image-${Date.now()}${ext}` +} + +function inferMime(fileName: string | undefined, kind: 'image' | 'file'): string { + if (kind === 'image') return 'image/jpeg' + const ext = path.extname(fileName || '').toLowerCase() + if (ext === '.pdf') return 'application/pdf' + if (ext === '.txt') return 'text/plain' + if (ext === '.png') return 'image/png' + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg' + if (ext === '.gif') return 'image/gif' + if (ext === '.webp') return 'image/webp' + return 'application/octet-stream' +} diff --git a/adapters/dingtalk/permission-card.ts b/adapters/dingtalk/permission-card.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa6bf64cb6c9e4a326c12f21149f24e3ecfc528f --- /dev/null +++ b/adapters/dingtalk/permission-card.ts @@ -0,0 +1,135 @@ +import { truncateInput } from '../common/format.js' +import { parsePermitCallbackData, type PermissionDecision } from '../common/permission.js' + +export const DINGTALK_PERMISSION_CARD_CALLBACK_ROUTE = 'permission' + +export type DingTalkPermissionCardAction = PermissionDecision & { + outTrackId?: string + chatId?: string +} + +export function buildDingTalkPermissionCardParams( + toolName: string, + input: unknown, + requestId: string, +): Record { + const allowValue = { action: 'permit', requestId, allowed: true } + const alwaysValue = { action: 'permit', requestId, allowed: true, rule: 'always' } + const denyValue = { action: 'permit', requestId, allowed: false } + + return { + title: 'Claude Code 需要权限确认', + toolName, + requestId, + inputPreview: truncateInput(input, 600), + allowText: '允许一次', + alwaysText: '永久允许', + denyText: '拒绝', + allowValue: JSON.stringify(allowValue), + alwaysValue: JSON.stringify(alwaysValue), + denyValue: JSON.stringify(denyValue), + permissionActions: JSON.stringify([ + { text: '允许一次', value: allowValue }, + { text: '永久允许', value: alwaysValue }, + { text: '拒绝', value: denyValue }, + ]), + sys_full_json_obj: JSON.stringify({ + order: ['title', 'toolName', 'inputPreview'], + actions: ['allowValue', 'alwaysValue', 'denyValue'], + }), + config: JSON.stringify({ autoLayout: true }), + } +} + +export function parseDingTalkPermissionCardAction(raw: unknown): DingTalkPermissionCardAction | null { + const root = parseMaybeJson(raw) + const values = collectValues(root) + + for (const value of values) { + if (typeof value === 'string') { + const direct = parsePermitCallbackData(value) + if (direct) return direct + const parsed = parseMaybeJson(value) + if (parsed !== value) { + const nested = parseDingTalkPermissionCardAction(parsed) + if (nested) return nested + } + } + } + + const objects = values.filter(isRecord) + for (const obj of objects) { + const requestId = readString(obj, ['requestId', 'request_id', 'permissionRequestId']) + if (!requestId) continue + + const action = readString(obj, ['action', 'actionType', 'decision', 'value', 'actionValue', 'command'])?.toLowerCase() + const allowed = readBoolean(obj, ['allowed', 'allow', 'approved']) + const rule = readString(obj, ['rule']) === 'always' ? 'always' : undefined + const outTrackId = readString(obj, ['outTrackId', 'cardInstanceId']) + const chatId = readString(obj, ['chatId', 'conversationId', 'openConversationId']) + + if (allowed !== undefined) return { requestId, allowed, rule, outTrackId, chatId } + if (action && ['allow', 'yes', 'approve', 'approved', 'permit'].includes(action)) { + return { requestId, allowed: true, rule, outTrackId, chatId } + } + if (action && ['always', 'allow-always', 'approve-always'].includes(action)) { + return { requestId, allowed: true, rule: 'always', outTrackId, chatId } + } + if (action && ['deny', 'no', 'reject', 'rejected'].includes(action)) { + return { requestId, allowed: false, outTrackId, chatId } + } + } + + return null +} + +function parseMaybeJson(value: unknown): unknown { + if (typeof value !== 'string') return value + const trimmed = value.trim() + if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return value + try { + return JSON.parse(trimmed) + } catch { + return value + } +} + +function collectValues(value: unknown, seen = new Set()): unknown[] { + const parsed = parseMaybeJson(value) + if (parsed && typeof parsed === 'object') { + if (seen.has(parsed)) return [] + seen.add(parsed) + } + + const values = [parsed] + if (Array.isArray(parsed)) { + for (const item of parsed) values.push(...collectValues(item, seen)) + } else if (isRecord(parsed)) { + for (const item of Object.values(parsed)) values.push(...collectValues(item, seen)) + } + return values +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function readString(obj: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = obj[key] + if (typeof value === 'string' && value.trim()) return value.trim() + } + return undefined +} + +function readBoolean(obj: Record, keys: string[]): boolean | undefined { + for (const key of keys) { + const value = obj[key] + if (typeof value === 'boolean') return value + if (typeof value === 'string') { + if (/^(true|yes|allow|approve|permit)$/i.test(value)) return true + if (/^(false|no|deny|reject)$/i.test(value)) return false + } + } + return undefined +} diff --git a/adapters/dingtalk/stream-state.ts b/adapters/dingtalk/stream-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ea4e527aaab53fd3c8fce08303a1bdf09981e6e --- /dev/null +++ b/adapters/dingtalk/stream-state.ts @@ -0,0 +1,30 @@ +import type { MessageBuffer } from '../common/message-buffer.js' +import type { DingTalkAiCardInstance } from './ai-card.js' + +export type DingTalkStreamingState = { + aiCardBuffers: Map + streamingCards: Map> + streamingCardText: Map + finalize?: () => Promise +} + +export function resetDingTalkStreamingState( + state: DingTalkStreamingState, + chatId: string, +): void { + state.aiCardBuffers.get(chatId)?.reset() + state.aiCardBuffers.delete(chatId) + state.streamingCards.delete(chatId) + state.streamingCardText.delete(chatId) +} + +export async function finishAndResetDingTalkStreamingState( + state: DingTalkStreamingState, + chatId: string, +): Promise { + await state.aiCardBuffers.get(chatId)?.complete() + if (state.finalize && (state.streamingCards.has(chatId) || state.streamingCardText.has(chatId))) { + await state.finalize() + } + resetDingTalkStreamingState(state, chatId) +} diff --git a/adapters/feishu/__tests__/card-errors.test.ts b/adapters/feishu/__tests__/card-errors.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dc958d7ca7b6e705c7d6fb6e5d05bafb6ff157ee --- /dev/null +++ b/adapters/feishu/__tests__/card-errors.test.ts @@ -0,0 +1,194 @@ +/** + * card-errors 单元测试 + */ + +import { describe, it, expect } from 'bun:test' +import { + CARD_ERROR, + CARD_CONTENT_SUB_ERROR, + extractLarkApiCode, + extractSubCode, + parseCardApiError, + isCardRateLimitError, + isCardTableLimitError, +} from '../card-errors.js' + +describe('extractLarkApiCode', () => { + it('从 err.code 直接提取', () => { + expect(extractLarkApiCode({ code: 230020 })).toBe(230020) + }) + + it('从 err.data.code 提取', () => { + expect(extractLarkApiCode({ data: { code: 230099 } })).toBe(230099) + }) + + it('从 err.response.data.code 提取(Axios 风格)', () => { + expect(extractLarkApiCode({ response: { data: { code: 99991672 } } })).toBe(99991672) + }) + + it('数字字符串被强制转成 number', () => { + expect(extractLarkApiCode({ code: '230020' })).toBe(230020) + }) + + it('三层结构优先级: err.code > err.data.code > err.response.data.code', () => { + const err = { + code: 1, + data: { code: 2 }, + response: { data: { code: 3 } }, + } + expect(extractLarkApiCode(err)).toBe(1) + }) + + it('none → undefined', () => { + expect(extractLarkApiCode({})).toBeUndefined() + expect(extractLarkApiCode(null)).toBeUndefined() + expect(extractLarkApiCode(undefined)).toBeUndefined() + expect(extractLarkApiCode('just a string')).toBeUndefined() + expect(extractLarkApiCode(new Error('plain'))).toBeUndefined() + }) + + it('非有限数字被忽略', () => { + expect(extractLarkApiCode({ code: NaN })).toBeUndefined() + expect(extractLarkApiCode({ code: 'not-a-number' })).toBeUndefined() + }) +}) + +describe('extractSubCode', () => { + it('识别标准的 ErrCode: 11310', () => { + const msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit' + expect(extractSubCode(msg)).toBe(11310) + }) + + it('无 ErrCode 时返回 null', () => { + expect(extractSubCode('random error message')).toBeNull() + expect(extractSubCode('')).toBeNull() + }) + + it('ErrCode 大小写容错(冒号后多空格)', () => { + expect(extractSubCode('ErrCode: 42')).toBe(42) + }) +}) + +describe('parseCardApiError', () => { + it('从 SDK 风格错误提取完整结构', () => { + const err = { code: 230099, msg: 'ErrCode: 11310; ErrMsg: card table number over limit' } + const parsed = parseCardApiError(err) + expect(parsed).toEqual({ + code: 230099, + subCode: 11310, + errMsg: 'ErrCode: 11310; ErrMsg: card table number over limit', + }) + }) + + it('从 Axios 风格错误(response.data.msg)提取', () => { + const err = { + response: { + data: { + code: 230020, + msg: 'rate limited', + }, + }, + } + const parsed = parseCardApiError(err) + expect(parsed?.code).toBe(230020) + expect(parsed?.errMsg).toBe('rate limited') + expect(parsed?.subCode).toBeNull() + }) + + it('无 code 时返回 null', () => { + expect(parseCardApiError({})).toBeNull() + expect(parseCardApiError(null)).toBeNull() + expect(parseCardApiError('string')).toBeNull() + }) + + it('有 code 无 msg 时 errMsg 为空字符串', () => { + const parsed = parseCardApiError({ code: 230020 }) + expect(parsed).toEqual({ code: 230020, subCode: null, errMsg: '' }) + }) + + it('fallback 到 err.message', () => { + const err = Object.assign(new Error('fallback text'), { code: 230099 }) + const parsed = parseCardApiError(err) + expect(parsed?.errMsg).toBe('fallback text') + }) +}) + +describe('isCardRateLimitError', () => { + it('识别 230020', () => { + expect(isCardRateLimitError({ code: 230020 })).toBe(true) + }) + + it('识别 Axios 风格 230020', () => { + expect(isCardRateLimitError({ response: { data: { code: 230020 } } })).toBe(true) + }) + + it('不匹配其他 code', () => { + expect(isCardRateLimitError({ code: 230099 })).toBe(false) + expect(isCardRateLimitError({ code: 99991672 })).toBe(false) + }) + + it('非错误对象返回 false', () => { + expect(isCardRateLimitError(null)).toBe(false) + expect(isCardRateLimitError({})).toBe(false) + expect(isCardRateLimitError(new Error('random'))).toBe(false) + }) +}) + +describe('isCardTableLimitError', () => { + const validMsg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; ' + + it('严格三条件匹配: code=230099 + subCode=11310 + msg 含 table number over limit', () => { + const err = { code: CARD_ERROR.CARD_CONTENT_FAILED, msg: validMsg } + expect(isCardTableLimitError(err)).toBe(true) + }) + + it('从 Axios 风格的 response.data 匹配', () => { + const err = { + response: { + data: { + code: 230099, + msg: validMsg, + }, + }, + } + expect(isCardTableLimitError(err)).toBe(true) + }) + + it('230099 + 11310 但没有 "table number over limit" 字样 → false(其它元素超限)', () => { + const err = { + code: CARD_ERROR.CARD_CONTENT_FAILED, + msg: 'ErrCode: 11310; ErrMsg: some other element limit; ', + } + expect(isCardTableLimitError(err)).toBe(false) + }) + + it('code 不是 230099 → false', () => { + const err = { code: 230020, msg: validMsg } + expect(isCardTableLimitError(err)).toBe(false) + }) + + it('没有 subCode → false', () => { + const err = { code: 230099, msg: 'card table number over limit (no ErrCode)' } + expect(isCardTableLimitError(err)).toBe(false) + }) + + it('不区分 "table number" 的大小写', () => { + const err = { + code: 230099, + msg: 'ErrCode: 11310; ErrMsg: CARD TABLE NUMBER OVER LIMIT; ', + } + expect(isCardTableLimitError(err)).toBe(true) + }) +}) + +describe('常量值', () => { + it('CARD_ERROR.RATE_LIMITED === 230020', () => { + expect(CARD_ERROR.RATE_LIMITED).toBe(230020) + }) + it('CARD_ERROR.CARD_CONTENT_FAILED === 230099', () => { + expect(CARD_ERROR.CARD_CONTENT_FAILED).toBe(230099) + }) + it('CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT === 11310', () => { + expect(CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT).toBe(11310) + }) +}) diff --git a/adapters/feishu/__tests__/cardkit.test.ts b/adapters/feishu/__tests__/cardkit.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbcc556137cb4a51009a6c25c06b707efca1fda7 --- /dev/null +++ b/adapters/feishu/__tests__/cardkit.test.ts @@ -0,0 +1,295 @@ +/** + * cardkit.ts 单元测试 + * + * 不调用真实的 Lark API —— 用 mock client 捕获调用参数,验证: + * - 每个函数构造的 payload 结构 + * - 非零 code 响应抛出 CardKitApiError(可被 card-errors 识别) + * - 缺失关键字段时抛错 + * - sequence 正确传递 + */ + +import { describe, it, expect } from 'bun:test' +import { + createCardEntity, + sendCardAsMessage, + streamCardContent, + setCardStreamingMode, + updateCardKitCard, + CardKitApiError, + STREAMING_ELEMENT_ID, +} from '../cardkit.js' +import { isCardRateLimitError, isCardTableLimitError } from '../card-errors.js' + +// --------------------------------------------------------------------------- +// Mock client factory +// --------------------------------------------------------------------------- + +type MockCall = { api: string; args: any } + +function makeMockClient(responses: Record) { + const calls: MockCall[] = [] + const recorder = (api: string, resp: any) => async (args: any) => { + calls.push({ api, args }) + if (typeof resp === 'function') { + return resp(args) + } + return resp + } + const client: any = { + cardkit: { + v1: { + card: { + create: recorder('cardkit.v1.card.create', responses['card.create']), + settings: recorder('cardkit.v1.card.settings', responses['card.settings']), + update: recorder('cardkit.v1.card.update', responses['card.update']), + }, + cardElement: { + content: recorder( + 'cardkit.v1.cardElement.content', + responses['cardElement.content'], + ), + }, + }, + }, + im: { + message: { + create: recorder('im.message.create', responses['im.message.create']), + reply: recorder('im.message.reply', responses['im.message.reply']), + }, + }, + } + return { client, calls } +} + +// --------------------------------------------------------------------------- +// createCardEntity +// --------------------------------------------------------------------------- + +describe('createCardEntity', () => { + it('构造 card_json payload 并返回 card_id', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { + code: 0, + data: { card_id: 'ck_abc_123' }, + }, + }) + const card = { schema: '2.0', body: { elements: [] } } + const id = await createCardEntity(client, card) + + expect(id).toBe('ck_abc_123') + expect(calls.length).toBe(1) + expect(calls[0]!.api).toBe('cardkit.v1.card.create') + expect(calls[0]!.args.data.type).toBe('card_json') + // data.data 应当是 card 的 JSON 字符串 + expect(calls[0]!.args.data.data).toBe(JSON.stringify(card)) + }) + + it('兼容顶层 card_id(某些 SDK 包装层)', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, card_id: 'top_level_id' }, + }) + const id = await createCardEntity(client, {}) + expect(id).toBe('top_level_id') + }) + + it('non-zero code 抛 CardKitApiError', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 230099, msg: 'something failed' }, + }) + await expect(createCardEntity(client, {})).rejects.toThrow(CardKitApiError) + }) + + it('code=0 但缺 card_id 抛错', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: {} }, + }) + await expect(createCardEntity(client, {})).rejects.toThrow(/missing card_id/) + }) +}) + +// --------------------------------------------------------------------------- +// sendCardAsMessage +// --------------------------------------------------------------------------- + +describe('sendCardAsMessage', () => { + it('无 replyTo: 走 im.message.create 使用 chat_id', async () => { + const { client, calls } = makeMockClient({ + 'im.message.create': { data: { message_id: 'om_new_msg_1' } }, + }) + const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz') + expect(mid).toBe('om_new_msg_1') + expect(calls.length).toBe(1) + expect(calls[0]!.api).toBe('im.message.create') + expect(calls[0]!.args.params.receive_id_type).toBe('chat_id') + expect(calls[0]!.args.data.receive_id).toBe('oc_chat_123') + expect(calls[0]!.args.data.msg_type).toBe('interactive') + // content 格式: {"type":"card","data":{"card_id":"xxx"}} + const parsed = JSON.parse(calls[0]!.args.data.content) + expect(parsed).toEqual({ type: 'card', data: { card_id: 'ck_id_xyz' } }) + }) + + it('有 replyTo: 走 im.message.reply', async () => { + const { client, calls } = makeMockClient({ + 'im.message.reply': { data: { message_id: 'om_reply_1' } }, + }) + const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz', 'om_parent') + expect(mid).toBe('om_reply_1') + expect(calls.length).toBe(1) + expect(calls[0]!.api).toBe('im.message.reply') + expect(calls[0]!.args.path.message_id).toBe('om_parent') + const parsed = JSON.parse(calls[0]!.args.data.content) + expect(parsed.data.card_id).toBe('ck_id_xyz') + }) + + it('缺 message_id 抛错', async () => { + const { client } = makeMockClient({ + 'im.message.create': { data: {} }, + }) + await expect(sendCardAsMessage(client, 'c', 'ck')).rejects.toThrow( + /missing message_id/, + ) + }) +}) + +// --------------------------------------------------------------------------- +// streamCardContent +// --------------------------------------------------------------------------- + +describe('streamCardContent', () => { + it('构造 content + sequence payload,path 包含 card_id + element_id', async () => { + const { client, calls } = makeMockClient({ + 'cardElement.content': { code: 0 }, + }) + await streamCardContent(client, 'ck_abc', STREAMING_ELEMENT_ID, 'hello', 42) + + expect(calls.length).toBe(1) + expect(calls[0]!.api).toBe('cardkit.v1.cardElement.content') + expect(calls[0]!.args.data).toEqual({ content: 'hello', sequence: 42 }) + expect(calls[0]!.args.path).toEqual({ + card_id: 'ck_abc', + element_id: STREAMING_ELEMENT_ID, + }) + }) + + it('STREAMING_ELEMENT_ID 常量 = "streaming_content"', () => { + expect(STREAMING_ELEMENT_ID).toBe('streaming_content') + }) + + it('230020 响应可被 isCardRateLimitError 识别', async () => { + const { client } = makeMockClient({ + 'cardElement.content': { code: 230020, msg: 'rate limited' }, + }) + try { + await streamCardContent(client, 'ck', 'el', 'x', 1) + expect('should have thrown').toBe('but did not') + } catch (err) { + expect(err).toBeInstanceOf(CardKitApiError) + expect(isCardRateLimitError(err)).toBe(true) + } + }) + + it('230099 + table limit msg 可被 isCardTableLimitError 识别', async () => { + const { client } = makeMockClient({ + 'cardElement.content': { + code: 230099, + msg: 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; ', + }, + }) + try { + await streamCardContent(client, 'ck', 'el', 'x', 1) + expect('should have thrown').toBe('but did not') + } catch (err) { + expect(err).toBeInstanceOf(CardKitApiError) + expect(isCardTableLimitError(err)).toBe(true) + } + }) +}) + +// --------------------------------------------------------------------------- +// setCardStreamingMode +// --------------------------------------------------------------------------- + +describe('setCardStreamingMode', () => { + it('streaming_mode=false + sequence 正确传递', async () => { + const { client, calls } = makeMockClient({ + 'card.settings': { code: 0 }, + }) + await setCardStreamingMode(client, 'ck_xxx', false, 99) + + expect(calls.length).toBe(1) + expect(calls[0]!.api).toBe('cardkit.v1.card.settings') + expect(calls[0]!.args.path).toEqual({ card_id: 'ck_xxx' }) + expect(calls[0]!.args.data.sequence).toBe(99) + // settings 是 JSON 字符串 + const settings = JSON.parse(calls[0]!.args.data.settings) + expect(settings).toEqual({ streaming_mode: false }) + }) + + it('streaming_mode=true 也能工作', async () => { + const { client, calls } = makeMockClient({ + 'card.settings': { code: 0 }, + }) + await setCardStreamingMode(client, 'ck', true, 1) + const settings = JSON.parse(calls[0]!.args.data.settings) + expect(settings).toEqual({ streaming_mode: true }) + }) +}) + +// --------------------------------------------------------------------------- +// updateCardKitCard +// --------------------------------------------------------------------------- + +describe('updateCardKitCard', () => { + it('把 card 包装成 card_json payload + sequence', async () => { + const { client, calls } = makeMockClient({ + 'card.update': { code: 0 }, + }) + const card = { schema: '2.0', body: { elements: [{ tag: 'markdown', content: 'done' }] } } + await updateCardKitCard(client, 'ck_final', card, 100) + + expect(calls.length).toBe(1) + expect(calls[0]!.api).toBe('cardkit.v1.card.update') + expect(calls[0]!.args.path).toEqual({ card_id: 'ck_final' }) + expect(calls[0]!.args.data.sequence).toBe(100) + expect(calls[0]!.args.data.card.type).toBe('card_json') + expect(calls[0]!.args.data.card.data).toBe(JSON.stringify(card)) + }) + + it('非零 code 抛 CardKitApiError', async () => { + const { client } = makeMockClient({ + 'card.update': { code: -1, msg: 'bad card' }, + }) + await expect(updateCardKitCard(client, 'ck', {}, 1)).rejects.toThrow(CardKitApiError) + }) +}) + +// --------------------------------------------------------------------------- +// CardKitApiError +// --------------------------------------------------------------------------- + +describe('CardKitApiError', () => { + it('携带 code 和 msg,可被 parseCardApiError 识别', () => { + const err = new CardKitApiError({ + api: 'card.update', + code: 230020, + msg: 'rate limited', + context: 'seq=5', + }) + expect(err.code).toBe(230020) + expect(err.msg).toBe('rate limited') + expect(err.name).toBe('CardKitApiError') + expect(isCardRateLimitError(err)).toBe(true) + }) + + it('消息包含 api 名和 context', () => { + const err = new CardKitApiError({ + api: 'cardElement.content', + code: 230099, + msg: 'oops', + context: 'seq=3 len=100', + }) + expect(err.message).toContain('cardElement.content') + expect(err.message).toContain('230099') + expect(err.message).toContain('seq=3 len=100') + }) +}) diff --git a/adapters/feishu/__tests__/extract-payload.test.ts b/adapters/feishu/__tests__/extract-payload.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..494b58c151cee9069ec345fd4cc1d218e6b898d3 --- /dev/null +++ b/adapters/feishu/__tests__/extract-payload.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'bun:test' +import { extractInboundPayload } from '../extract-payload.js' + +describe('extractInboundPayload', () => { + it('pulls text out of a text message', () => { + const result = extractInboundPayload( + JSON.stringify({ text: 'hello world' }), + 'text', + ) + expect(result.text).toBe('hello world') + expect(result.pendingDownloads).toEqual([]) + }) + + it('pulls text out of a post (rich text) message', () => { + const content = JSON.stringify({ + zh_cn: { + content: [[{ tag: 'text', text: 'hi ' }, { tag: 'text', text: 'there' }]], + }, + }) + const result = extractInboundPayload(content, 'post') + expect(result.text).toBe('hi there') + expect(result.pendingDownloads).toEqual([]) + }) + + it('identifies an image message as a pending image download', () => { + const content = JSON.stringify({ image_key: 'img_key_abc' }) + const result = extractInboundPayload(content, 'image') + expect(result.text).toBe('') + expect(result.pendingDownloads).toEqual([ + { kind: 'image', fileKey: 'img_key_abc' }, + ]) + }) + + it('identifies a file message as a pending file download with file_name', () => { + const content = JSON.stringify({ + file_key: 'file_key_xyz', + file_name: 'spec.pdf', + }) + const result = extractInboundPayload(content, 'file') + expect(result.pendingDownloads).toEqual([ + { kind: 'file', fileKey: 'file_key_xyz', fileName: 'spec.pdf' }, + ]) + }) + + it('identifies file_archive the same way as file', () => { + const content = JSON.stringify({ file_key: 'fk1', file_name: 'x.zip' }) + const result = extractInboundPayload(content, 'file_archive') + expect(result.pendingDownloads).toEqual([ + { kind: 'file', fileKey: 'fk1', fileName: 'x.zip' }, + ]) + }) + + it('extracts img + file elements from a post message', () => { + const content = JSON.stringify({ + zh_cn: { + content: [ + [{ tag: 'text', text: 'look: ' }], + [{ tag: 'img', image_key: 'img_post_1' }], + [{ tag: 'text', text: ' and ' }], + [{ tag: 'file', file_key: 'file_post_1', file_name: 'note.txt' }], + ], + }, + }) + const result = extractInboundPayload(content, 'post') + expect(result.text).toBe('look: and ') + expect(result.pendingDownloads).toEqual([ + { kind: 'image', fileKey: 'img_post_1' }, + { kind: 'file', fileKey: 'file_post_1', fileName: 'note.txt' }, + ]) + }) + + it('returns empty on malformed JSON', () => { + const result = extractInboundPayload('not json', 'text') + expect(result.text).toBe('') + expect(result.pendingDownloads).toEqual([]) + }) +}) diff --git a/adapters/feishu/__tests__/feishu.test.ts b/adapters/feishu/__tests__/feishu.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..143776394e2205eec2b9c492b03a7a656575ef07 --- /dev/null +++ b/adapters/feishu/__tests__/feishu.test.ts @@ -0,0 +1,899 @@ +/** + * 飞书 Adapter 翻译逻辑测试 + * + * 不启动真实 Bot,只测试事件解析和消息翻译逻辑。 + */ + +import { describe, it, expect } from 'bun:test' +import { isOutsideWorkDir } from '../path-safety.js' + +// ---------- helpers extracted from feishu/index.ts for testability ---------- + +function extractText(content: string, msgType: string): string | null { + try { + const parsed = JSON.parse(content) + if (msgType === 'text') { + return parsed.text ?? null + } + if (msgType === 'post') { + const zhContent = parsed.zh_cn?.content ?? parsed.en_us?.content ?? [] + return zhContent + .flat() + .filter((n: any) => n.tag === 'text' || n.tag === 'md') + .map((n: any) => n.text ?? n.content ?? '') + .join('') + .trim() || null + } + return null + } catch { + return null + } +} + +function isBotMentioned( + mentions: Array<{ id?: { open_id?: string } }> | undefined, + botOpenId: string, +): boolean { + if (!mentions || !botOpenId) return false + return mentions.some((m) => m.id?.open_id === botOpenId) +} + +function stripMentions(text: string): string { + return text.replace(/@_user_\d+/g, '').trim() +} + +type RecentProject = { + projectPath: string + realPath: string + projectName: string + isGit: boolean + repoName: string | null + branch: string | null + modifiedAt: string + sessionCount: number +} + +function prettyPath(realPath: string, maxLen = 64): string { + const home = process.env.HOME + let p = realPath + if (home) { + if (p === home) return '~' + if (p.startsWith(`${home}/`)) p = `~${p.slice(home.length)}` + } + if (p.length <= maxLen) return p + const tailLen = Math.floor(maxLen * 0.65) + const headLen = maxLen - tailLen - 1 + return `${p.slice(0, headLen)}…${p.slice(-tailLen)}` +} + +function buildProjectPickerCard(projects: RecentProject[]): Record { + const items = projects.slice(0, 10) + const total = projects.length + const subtitleText = + total > items.length + ? `共 ${total} 个最近项目,显示前 ${items.length}` + : `共 ${total} 个最近项目` + + const rows = items.map((p, i) => { + const branch = p.branch ? ` · *${p.branch}*` : '' + return { + tag: 'column_set', + flex_mode: 'stretch', + horizontal_spacing: '8px', + margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0', + columns: [ + { + tag: 'column', + width: 'weighted', + weight: 1, + vertical_align: 'center', + elements: [ + { + tag: 'markdown', + content: `**${p.projectName}**${branch}`, + }, + { + tag: 'markdown', + content: prettyPath(p.realPath, 56), + text_size: 'notation', + margin: '2px 0 0 0', + }, + ], + }, + { + tag: 'column', + width: 'auto', + vertical_align: 'center', + elements: [ + { + tag: 'button', + text: { tag: 'plain_text', content: '选择' }, + type: i === 0 ? 'primary' : 'default', + size: 'small', + value: { + action: 'pick_project', + realPath: p.realPath, + projectName: p.projectName, + }, + }, + ], + }, + ], + } + }) + + return { + schema: '2.0', + config: { + wide_screen_mode: true, + update_multi: true, + }, + header: { + title: { tag: 'plain_text', content: '📁 选择项目' }, + subtitle: { tag: 'plain_text', content: subtitleText }, + template: 'blue', + }, + body: { + elements: [ + ...rows, + { tag: 'hr', margin: '14px 0 0 0' }, + { + tag: 'markdown', + content: '💡 点击右侧 **选择** 按钮,或发送 `/new <项目名>`', + text_size: 'notation', + margin: '6px 0 0 0', + }, + ], + }, + } +} + +// ---------- permission card helpers (mirrored from feishu/index.ts) ---------- + +type ToolCallSummary = { + icon: string + label: string + target?: string + filePath?: string +} + +function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary { + const rec: Record = + input && typeof input === 'object' ? (input as Record) : {} + const str = (key: string): string | undefined => + typeof rec[key] === 'string' ? (rec[key] as string) : undefined + + switch (toolName) { + case 'Write': { + const fp = str('file_path') + return { icon: '✏️', label: '写入文件', target: fp, filePath: fp } + } + case 'Edit': + case 'MultiEdit': + case 'NotebookEdit': { + const fp = str('file_path') ?? str('notebook_path') + return { icon: '✏️', label: '修改文件', target: fp, filePath: fp } + } + case 'Read': { + const fp = str('file_path') + return { icon: '📖', label: '读取文件', target: fp, filePath: fp } + } + case 'Bash': + case 'BashOutput': { + return { icon: '🖥️', label: '执行命令', target: str('command') } + } + case 'Grep': { + const pattern = str('pattern') + return { + icon: '🔍', + label: '搜索内容', + target: pattern ? `pattern: ${pattern}` : undefined, + filePath: str('path'), + } + } + case 'Glob': { + const pattern = str('pattern') + return { + icon: '📁', + label: '查找文件', + target: pattern ? `pattern: ${pattern}` : undefined, + filePath: str('path'), + } + } + case 'WebFetch': + return { icon: '🌐', label: '访问网页', target: str('url') } + case 'WebSearch': + return { icon: '🌐', label: '搜索网页', target: str('query') } + default: + return { icon: '🔧', label: toolName } + } +} + +function truncateTarget(s: string, maxLen = 160): string { + if (s.length <= maxLen) return s + return s.slice(0, maxLen - 1) + '…' +} + +function buildPermissionCard( + toolName: string, + input: unknown, + requestId: string, + workDir?: string, +): Record { + const summary = summarizeToolCall(toolName, input) + const crossDir = Boolean( + workDir && summary.filePath && isOutsideWorkDir(summary.filePath, workDir), + ) + + const elements: Record[] = [ + { + tag: 'markdown', + content: `${summary.icon} **${summary.label}** \`${toolName}\``, + }, + ] + + if (summary.target) { + const shown = summary.filePath + ? prettyPath(summary.target, 80) + : truncateTarget(summary.target, 160) + elements.push({ + tag: 'markdown', + content: '```\n' + shown + '\n```', + margin: '4px 0 0 0', + }) + } + + if (crossDir) { + elements.push({ + tag: 'markdown', + content: '⚠️ **该操作位于当前项目目录之外**', + margin: '8px 0 0 0', + text_size: 'notation', + }) + } + + elements.push({ tag: 'hr', margin: '12px 0 0 0' }) + + elements.push({ + tag: 'column_set', + flex_mode: 'stretch', + horizontal_spacing: '8px', + margin: '8px 0 0 0', + columns: [ + { + tag: 'column', + width: 'weighted', + weight: 1, + vertical_align: 'center', + elements: [ + { + tag: 'button', + text: { tag: 'plain_text', content: '✅ 允许' }, + type: 'primary', + size: 'medium', + value: { action: 'permit', requestId, allowed: true }, + }, + ], + }, + { + tag: 'column', + width: 'weighted', + weight: 1, + vertical_align: 'center', + elements: [ + { + tag: 'button', + text: { tag: 'plain_text', content: '♾️ 永久允许' }, + type: 'default', + size: 'medium', + value: { action: 'permit', requestId, allowed: true, rule: 'always' }, + }, + ], + }, + { + tag: 'column', + width: 'weighted', + weight: 1, + vertical_align: 'center', + elements: [ + { + tag: 'button', + text: { tag: 'plain_text', content: '❌ 拒绝' }, + type: 'danger', + size: 'medium', + value: { action: 'permit', requestId, allowed: false }, + }, + ], + }, + ], + }) + + return { + schema: '2.0', + config: { + wide_screen_mode: false, + update_multi: true, + }, + header: { + title: { tag: 'plain_text', content: '🔐 需要权限确认' }, + subtitle: { + tag: 'plain_text', + content: crossDir ? '⚠️ 跨目录操作' : toolName, + }, + template: crossDir ? 'red' : 'orange', + padding: '12px 12px 12px 12px', + icon: { tag: 'standard_icon', token: 'lock-chat_filled' }, + }, + body: { elements }, + } +} + +// ---------- tests ---------- + +describe('Feishu: event parsing', () => { + describe('extractText', () => { + it('extracts text from text message', () => { + const content = JSON.stringify({ text: 'hello world' }) + expect(extractText(content, 'text')).toBe('hello world') + }) + + it('extracts text from post message (zh_cn)', () => { + const content = JSON.stringify({ + zh_cn: { + content: [[ + { tag: 'text', text: 'Hello ' }, + { tag: 'text', text: 'World' }, + ]], + }, + }) + expect(extractText(content, 'post')).toBe('Hello World') + }) + + it('extracts text from post message with md tag', () => { + const content = JSON.stringify({ + zh_cn: { + content: [[{ tag: 'md', text: '**bold** text' }]], + }, + }) + expect(extractText(content, 'post')).toBe('**bold** text') + }) + + it('returns null for unsupported message types', () => { + expect(extractText('{}', 'image')).toBeNull() + expect(extractText('{}', 'audio')).toBeNull() + }) + + it('returns null for malformed content', () => { + expect(extractText('not-json', 'text')).toBeNull() + }) + + it('returns null for empty text', () => { + const content = JSON.stringify({ text: '' }) + // empty string is falsy, so ?? null returns '' + expect(extractText(content, 'text')).toBe('') + }) + }) + + describe('isBotMentioned', () => { + const botId = 'ou_bot_123' + + it('returns true when bot is mentioned', () => { + const mentions = [ + { id: { open_id: 'ou_user_1' } }, + { id: { open_id: 'ou_bot_123' } }, + ] + expect(isBotMentioned(mentions, botId)).toBe(true) + }) + + it('returns false when bot is not mentioned', () => { + const mentions = [ + { id: { open_id: 'ou_user_1' } }, + { id: { open_id: 'ou_user_2' } }, + ] + expect(isBotMentioned(mentions, botId)).toBe(false) + }) + + it('returns false for undefined mentions', () => { + expect(isBotMentioned(undefined, botId)).toBe(false) + }) + + it('returns false for empty mentions', () => { + expect(isBotMentioned([], botId)).toBe(false) + }) + }) + + describe('stripMentions', () => { + it('removes @_user_N patterns', () => { + expect(stripMentions('@_user_1 hello world')).toBe('hello world') + }) + + it('removes multiple mentions', () => { + expect(stripMentions('@_user_1 @_user_2 test')).toBe('test') + }) + + it('leaves text without mentions unchanged', () => { + expect(stripMentions('hello world')).toBe('hello world') + }) + + it('trims whitespace', () => { + expect(stripMentions(' @_user_1 hello ')).toBe('hello') + }) + }) +}) + +describe('Feishu: permission card', () => { + // Helpers to reach into Schema 2.0 body.elements + function getBodyElements(card: Record): any[] { + return ((card.body as any).elements ?? []) as any[] + } + function getActionRow(card: Record): any { + return getBodyElements(card).find((el) => el.tag === 'column_set') + } + function getButtons(card: Record): any[] { + return getActionRow(card).columns.map( + (c: any) => c.elements.find((e: any) => e.tag === 'button'), + ) + } + + // ----- Schema 2.0 regression ----- + + it('uses Schema 2.0 with body.elements wrapper (not top-level elements)', () => { + const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc') + expect(card.schema).toBe('2.0') + expect(card.elements).toBeUndefined() // old bug had top-level elements + expect((card.body as any).elements).toBeDefined() + expect((card.config as any).update_multi).toBe(true) + expect((card.config as any).wide_screen_mode).toBe(false) // mobile-first + }) + + it('header has title, subtitle, template, icon', () => { + const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc') + const header = card.header as any + expect(header.title.content).toContain('权限确认') + expect(header.subtitle.content).toBe('Bash') + expect(header.template).toBe('orange') + expect(header.icon.tag).toBe('standard_icon') + }) + + // ----- Three buttons ----- + + it('has three action buttons in order: 允许 | 永久允许 | 拒绝', () => { + const card = buildPermissionCard('Read', {}, 'xyz') + const [allow, always, deny] = getButtons(card) + expect(allow.text.content).toContain('允许') + expect(allow.type).toBe('primary') + expect(always.text.content).toContain('永久允许') + expect(always.type).toBe('default') + expect(deny.text.content).toContain('拒绝') + expect(deny.type).toBe('danger') + }) + + it('允许 button carries allowed=true and no rule', () => { + const card = buildPermissionCard('Read', {}, 'req-1') + const [allow] = getButtons(card) + expect(allow.value).toEqual({ + action: 'permit', + requestId: 'req-1', + allowed: true, + }) + expect(allow.value.rule).toBeUndefined() + }) + + it('永久允许 button carries allowed=true + rule=always', () => { + const card = buildPermissionCard('Read', {}, 'req-2') + const always = getButtons(card)[1] + expect(always.value).toEqual({ + action: 'permit', + requestId: 'req-2', + allowed: true, + rule: 'always', + }) + }) + + it('拒绝 button carries allowed=false and no rule', () => { + const card = buildPermissionCard('Read', {}, 'req-3') + const deny = getButtons(card)[2] + expect(deny.value).toEqual({ + action: 'permit', + requestId: 'req-3', + allowed: false, + }) + }) + + // ----- Tool summary rendering ----- + + it('renders Write with ✏️ 写入文件 header and file path target', () => { + const card = buildPermissionCard( + 'Write', + { file_path: '/tmp/output.txt', content: 'hi' }, + 'req', + ) + const elements = getBodyElements(card) + expect(elements[0].content).toContain('✏️') + expect(elements[0].content).toContain('写入文件') + expect(elements[0].content).toContain('`Write`') + // Target rendered as fenced code block + expect(elements[1].content).toContain('/tmp/output.txt') + expect(elements[1].content.startsWith('```')).toBe(true) + }) + + it('renders Edit with ✏️ 修改文件', () => { + const card = buildPermissionCard( + 'Edit', + { file_path: '/a/b.ts', old_string: 'x', new_string: 'y' }, + 'req', + ) + expect(getBodyElements(card)[0].content).toContain('修改文件') + }) + + it('renders Bash with 🖥️ 执行命令 and command target', () => { + const card = buildPermissionCard( + 'Bash', + { command: 'rm -rf /tmp/x' }, + 'req', + ) + const elements = getBodyElements(card) + expect(elements[0].content).toContain('🖥️') + expect(elements[0].content).toContain('执行命令') + expect(elements[1].content).toContain('rm -rf /tmp/x') + }) + + it('truncates very long Bash commands to 160 chars', () => { + const longCmd = 'echo ' + 'x'.repeat(500) + const card = buildPermissionCard('Bash', { command: longCmd }, 'req') + const targetEl = getBodyElements(card)[1] + expect(targetEl.content).toContain('…') + // Fenced code wraps ~10 extra chars + expect(targetEl.content.length).toBeLessThanOrEqual(180) + }) + + it('renders Grep with 🔍 搜索内容 and pattern target', () => { + const card = buildPermissionCard( + 'Grep', + { pattern: 'TODO', path: '/src' }, + 'req', + ) + const elements = getBodyElements(card) + expect(elements[0].content).toContain('🔍') + expect(elements[1].content).toContain('TODO') + }) + + it('renders WebFetch with 🌐 访问网页 and url target', () => { + const card = buildPermissionCard( + 'WebFetch', + { url: 'https://example.com/api' }, + 'req', + ) + const elements = getBodyElements(card) + expect(elements[0].content).toContain('🌐') + expect(elements[0].content).toContain('访问网页') + expect(elements[1].content).toContain('https://example.com/api') + }) + + it('falls back to 🔧 for unknown tools', () => { + const card = buildPermissionCard('CustomTool', { foo: 'bar' }, 'req') + expect(getBodyElements(card)[0].content).toContain('🔧') + expect(getBodyElements(card)[0].content).toContain('CustomTool') + }) + + it('has no target line when input is empty', () => { + const card = buildPermissionCard('Bash', {}, 'req') + const elements = getBodyElements(card) + // elements: [header_md, hr, action_column_set] + expect(elements[1].tag).toBe('hr') + }) + + // ----- Cross-directory detection ----- + + it('does NOT show cross-dir warning when file is inside workDir', () => { + const card = buildPermissionCard( + 'Write', + { file_path: '/Users/me/proj/src/a.ts' }, + 'req', + '/Users/me/proj', + ) + const elements = getBodyElements(card) + const hasWarn = elements.some( + (el) => typeof el.content === 'string' && el.content.includes('项目目录之外'), + ) + expect(hasWarn).toBe(false) + expect((card.header as any).template).toBe('orange') + expect((card.header as any).subtitle.content).toBe('Write') + }) + + it('DOES show cross-dir warning when file is outside workDir (red template)', () => { + const card = buildPermissionCard( + 'Write', + { file_path: '/tmp/evil.sh' }, + 'req', + '/Users/me/proj', + ) + const elements = getBodyElements(card) + const warn = elements.find( + (el) => typeof el.content === 'string' && el.content.includes('项目目录之外'), + ) + expect(warn).toBeDefined() + expect((card.header as any).template).toBe('red') + expect((card.header as any).subtitle.content).toContain('跨目录') + }) + + it('does NOT check cross-dir for Bash (no filePath)', () => { + const card = buildPermissionCard( + 'Bash', + { command: 'rm -rf /tmp/x' }, + 'req', + '/Users/me/proj', + ) + expect((card.header as any).template).toBe('orange') + }) + + it('does not warn when workDir is not provided', () => { + const card = buildPermissionCard( + 'Write', + { file_path: '/tmp/x.ts' }, + 'req', + // workDir omitted + ) + const elements = getBodyElements(card) + const hasWarn = elements.some( + (el) => typeof el.content === 'string' && el.content.includes('项目目录之外'), + ) + expect(hasWarn).toBe(false) + }) +}) + +describe('Feishu: isOutsideWorkDir', () => { + it('returns false for file inside workDir', () => { + expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj')).toBe(false) + }) + + it('returns false for file directly in workDir', () => { + expect(isOutsideWorkDir('/Users/me/proj/a.ts', '/Users/me/proj')).toBe(false) + }) + + it('returns true for file in a sibling directory', () => { + expect(isOutsideWorkDir('/Users/me/other/a.ts', '/Users/me/proj')).toBe(true) + }) + + it('returns true for /tmp file', () => { + expect(isOutsideWorkDir('/tmp/evil.sh', '/Users/me/proj')).toBe(true) + }) + + it('handles workDir with trailing slash', () => { + expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj/')).toBe(false) + }) + + it('resolves relative paths against workDir', () => { + expect(isOutsideWorkDir('src/a.ts', '/Users/me/proj')).toBe(false) + expect(isOutsideWorkDir('../other/a.ts', '/Users/me/proj')).toBe(true) + }) + + it('does not match prefix collisions (proj vs proj2)', () => { + // /Users/me/proj2/a.ts starts with "/Users/me/proj" as a string + // but is NOT inside /Users/me/proj + expect(isOutsideWorkDir('/Users/me/proj2/a.ts', '/Users/me/proj')).toBe(true) + }) +}) + +describe('Feishu: project picker card', () => { + const sampleProjects: RecentProject[] = [ + { + projectPath: '/Users/dev/claude-code-haha', + realPath: '/Users/dev/claude-code-haha', + projectName: 'claude-code-haha', + isGit: true, + repoName: 'claude-code-haha', + branch: 'main', + modifiedAt: '2026-04-11T00:00:00Z', + sessionCount: 3, + }, + { + projectPath: '/Users/dev/desktop', + realPath: '/Users/dev/desktop', + projectName: 'desktop', + isGit: false, + repoName: null, + branch: null, + modifiedAt: '2026-04-10T00:00:00Z', + sessionCount: 1, + }, + ] + + function getBodyElements(card: Record): any[] { + return ((card.body as any).elements ?? []) as any[] + } + + function getRows(card: Record): any[] { + return getBodyElements(card).filter((el) => el.tag === 'column_set') + } + + function getRowButton(row: any): any { + const buttonCol = row.columns.find((c: any) => + c.elements.some((e: any) => e.tag === 'button'), + ) + return buttonCol.elements.find((e: any) => e.tag === 'button') + } + + function getRowInfoElements(row: any): any[] { + const infoCol = row.columns.find((c: any) => + c.elements.every((e: any) => e.tag === 'markdown'), + ) + return infoCol.elements + } + + it('uses Schema 2.0 with body.elements wrapper', () => { + const card = buildProjectPickerCard(sampleProjects) + expect(card.schema).toBe('2.0') + expect((card.config as any).update_multi).toBe(true) + expect((card.body as any).elements).toBeDefined() + }) + + it('header has title and project-count subtitle', () => { + const card = buildProjectPickerCard(sampleProjects) + expect((card.header as any).title.content).toContain('选择项目') + expect((card.header as any).subtitle.content).toContain('2') + expect((card.header as any).subtitle.content).toContain('最近项目') + }) + + it('subtitle notes truncation when more than 10 projects exist', () => { + const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({ + ...sampleProjects[0]!, + projectName: `proj-${i}`, + realPath: `/p/${i}`, + })) + const card = buildProjectPickerCard(many) + const subtitle = (card.header as any).subtitle.content + expect(subtitle).toContain('15') + expect(subtitle).toContain('显示前 10') + }) + + it('body contains one column_set row per project', () => { + const card = buildProjectPickerCard(sampleProjects) + expect(getRows(card).length).toBe(2) + }) + + it('each row has exactly 2 columns: info (weighted) + button (auto)', () => { + const card = buildProjectPickerCard(sampleProjects) + for (const row of getRows(card)) { + expect(row.columns.length).toBe(2) + expect(row.columns[0].width).toBe('weighted') + expect(row.columns[0].vertical_align).toBe('center') + expect(row.columns[1].width).toBe('auto') + expect(row.columns[1].vertical_align).toBe('center') + } + }) + + it('info column has title markdown + notation path markdown', () => { + const card = buildProjectPickerCard(sampleProjects) + const row1 = getRows(card)[0] + const info = getRowInfoElements(row1) + + expect(info.length).toBe(2) + // Title markdown + expect(info[0].tag).toBe('markdown') + expect(info[0].content).toContain('**claude-code-haha**') + expect(info[0].content).toContain('*main*') + // Path markdown (notation = small grey) + expect(info[1].tag).toBe('markdown') + expect(info[1].text_size).toBe('notation') + expect(info[1].content).toContain('claude-code-haha') + }) + + it('row without branch has no separator dot in title', () => { + const card = buildProjectPickerCard(sampleProjects) + const row2 = getRows(card)[1] + const title = getRowInfoElements(row2)[0].content + expect(title).toContain('**desktop**') + expect(title).not.toContain('·') + }) + + it('row button says 选择 with small size and carries per-project value', () => { + const card = buildProjectPickerCard(sampleProjects) + const rows = getRows(card) + + const btn1 = getRowButton(rows[0]) + expect(btn1.text.content).toBe('选择') + expect(btn1.size).toBe('small') + expect(btn1.value.action).toBe('pick_project') + expect(btn1.value.realPath).toBe('/Users/dev/claude-code-haha') + expect(btn1.value.projectName).toBe('claude-code-haha') + + const btn2 = getRowButton(rows[1]) + expect(btn2.value.realPath).toBe('/Users/dev/desktop') + }) + + it('first row button is primary, rest are default', () => { + const card = buildProjectPickerCard(sampleProjects) + const rows = getRows(card) + expect(getRowButton(rows[0]).type).toBe('primary') + expect(getRowButton(rows[1]).type).toBe('default') + }) + + it('body tail has hr and notation footer hint', () => { + const card = buildProjectPickerCard(sampleProjects) + const elements = getBodyElements(card) + const hrIdx = elements.findIndex((el) => el.tag === 'hr') + expect(hrIdx).toBeGreaterThan(0) + expect(elements[hrIdx + 1].tag).toBe('markdown') + expect(elements[hrIdx + 1].text_size).toBe('notation') + }) + + it('caps to first 10 projects', () => { + const many: RecentProject[] = Array.from({ length: 15 }, (_, i) => ({ + ...sampleProjects[0]!, + projectName: `proj-${i}`, + realPath: `/p/${i}`, + })) + const card = buildProjectPickerCard(many) + const rows = getRows(card) + expect(rows.length).toBe(10) + expect(getRowButton(rows[9]).value.realPath).toBe('/p/9') + }) + + it('uses ~ shortcut when path is under $HOME', () => { + const home = process.env.HOME + if (!home) return + const project: RecentProject = { + ...sampleProjects[0]!, + realPath: `${home}/some/sub/dir`, + projectName: 'sub-dir', + } + const card = buildProjectPickerCard([project]) + const pathEl = getRowInfoElements(getRows(card)[0])[1] + expect(pathEl.content).toBe('~/some/sub/dir') + }) + + it('middle-truncates very long paths with ellipsis', () => { + const veryLong = '/x/'.repeat(40) + 'project' // ~123 chars + const project: RecentProject = { + ...sampleProjects[0]!, + realPath: veryLong, + projectName: 'project', + } + const card = buildProjectPickerCard([project]) + const content = getRowInfoElements(getRows(card)[0])[1].content + expect(content).toContain('…') + expect(content.length).toBeLessThanOrEqual(56) + expect(content.endsWith('project')).toBe(true) + }) +}) + +describe('Feishu: card.action.trigger parsing', () => { + it('parses permit action from event', () => { + const event = { + operator: { open_id: 'ou_user_1' }, + action: { value: { action: 'permit', requestId: 'abcde', allowed: true } }, + context: { open_chat_id: 'oc_chat_123' }, + } + + expect(event.action.value.action).toBe('permit') + expect(event.action.value.requestId).toBe('abcde') + expect(event.action.value.allowed).toBe(true) + expect(event.context.open_chat_id).toBe('oc_chat_123') + }) + + it('parses pick_project action from event', () => { + const event = { + operator: { open_id: 'ou_user_1' }, + action: { + value: { + action: 'pick_project', + realPath: '/Users/dev/claude-code-haha', + projectName: 'claude-code-haha', + }, + }, + context: { open_chat_id: 'oc_chat_123' }, + } + + expect(event.action.value.action).toBe('pick_project') + expect(event.action.value.realPath).toBe('/Users/dev/claude-code-haha') + expect(event.action.value.projectName).toBe('claude-code-haha') + }) + + it('ignores non-handled actions', () => { + const event = { + action: { value: { action: 'other_action' } }, + } + expect(['permit', 'pick_project']).not.toContain(event.action.value.action) + }) +}) diff --git a/adapters/feishu/__tests__/flush-controller.test.ts b/adapters/feishu/__tests__/flush-controller.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3324c0cff70c2221e609b26693988a2a8d7dbbce --- /dev/null +++ b/adapters/feishu/__tests__/flush-controller.test.ts @@ -0,0 +1,290 @@ +/** + * FlushController 单元测试 + * + * 覆盖: + * - 基础: cardMessageReady gate, complete() 锁死 + * - 节流窗口: 立即 flush / 延迟 flush + * - Mutex: 进行中的 flush 重复调用标记 needsReflush + * - Conflict reflush: API 结束后自动补一次 + * - 长间隔批量: elapsed > 2000ms 后延迟 300ms 再 flush + * - waitForFlush: 等当前 flush 结束 + */ + +import { describe, it, expect } from 'bun:test' +import { FlushController, THROTTLE } from '../flush-controller.js' + +// 创建一个可控的 doFlush —— 返回一个 Promise 可以手动 resolve +function makeControllableFlush() { + const calls: string[] = [] + let resolveCurrent: (() => void) | null = null + let latch: Promise | null = null + + const doFlush = async () => { + calls.push('flush-start') + if (latch) { + await latch + latch = null + } + calls.push('flush-end') + } + + const blockNext = () => { + latch = new Promise((resolve) => { + resolveCurrent = resolve + }) + } + + const unblock = () => { + if (resolveCurrent) { + const r = resolveCurrent + resolveCurrent = null + r() + } + } + + return { doFlush, calls, blockNext, unblock } +} + +async function sleep(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)) +} + +// --------------------------------------------------------------------------- +// Basic gating +// --------------------------------------------------------------------------- + +describe('FlushController: cardMessageReady gate', () => { + it('在 cardMessageReady=false 时不 flush', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + await fc.flush() + await fc.throttledUpdate(50) + expect(count).toBe(0) + }) + + it('setCardMessageReady(true) 后 flush 可执行', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + fc.setCardMessageReady(true) + await fc.flush() + expect(count).toBe(1) + }) + + it('setCardMessageReady(true) 同步初始化 lastUpdateTime —— 刚 ready 时 throttledUpdate 被节流窗口阻挡', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + fc.setCardMessageReady(true) + // 立即调用 throttledUpdate 500ms 窗口,首次 elapsed≈0 → 进入延迟分支 + await fc.throttledUpdate(500) + // 同步阶段还没到 500ms,不应触发 flush + expect(count).toBe(0) + // 500+ms 后延迟 timer 触发 + await sleep(600) + expect(count).toBe(1) + }) +}) + +describe('FlushController: complete()', () => { + it('complete() 后拒绝新 flush', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + fc.setCardMessageReady(true) + fc.complete() + await fc.flush() + await fc.throttledUpdate(50) + expect(count).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// Throttle window +// --------------------------------------------------------------------------- + +describe('FlushController: 节流窗口', () => { + it('超过窗口立即 flush', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + fc.setCardMessageReady(true) + // 手动把 lastUpdateTime 挪远(等同于已过了节流窗口) + await sleep(150) + await fc.throttledUpdate(100) + expect(count).toBe(1) + }) + + it('在窗口内首次调用安排延迟 flush', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + fc.setCardMessageReady(true) // lastUpdateTime = now + + await fc.throttledUpdate(200) + expect(count).toBe(0) // 延迟中,还没触发 + + await sleep(300) + expect(count).toBe(1) // 200ms 后延迟 timer 触发 + }) + + it('窗口内多次调用复用同一个延迟 timer(不重复 flush)', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + fc.setCardMessageReady(true) + + await fc.throttledUpdate(200) + await fc.throttledUpdate(200) + await fc.throttledUpdate(200) + await fc.throttledUpdate(200) + + await sleep(300) + expect(count).toBe(1) + }) +}) + +// --------------------------------------------------------------------------- +// Mutex + conflict reflush +// --------------------------------------------------------------------------- + +describe('FlushController: mutex + 冲突重刷', () => { + it('flush 进行中的重复调用不并发执行', async () => { + const { doFlush, calls, blockNext, unblock } = makeControllableFlush() + const fc = new FlushController(doFlush) + fc.setCardMessageReady(true) + + blockNext() + const p1 = fc.flush() // flush-start 后被 latch 卡住 + // 让事件循环走一轮,确保第一次 flush 进入 body + await sleep(10) + // 第二次调用时第一次还没结束 —— 应被 mutex 挡住 + const p2 = fc.flush() + + // 两次 Promise 都已登记,但都还没 end + expect(calls).toEqual(['flush-start']) + + unblock() + await p1 + await p2 + // 第一次跑完后,由于 needsReflush 被标记,会触发一次补刷 + // (conflict reflush 是通过 setTimeout 0 调度的,需要让它跑完) + await sleep(20) + + // 第一次 flush-start + flush-end,然后冲突补刷再一次 start + end + expect(calls).toEqual([ + 'flush-start', 'flush-end', + 'flush-start', 'flush-end', + ]) + }) + + it('flush 进行中的 throttledUpdate 也会触发补刷', async () => { + const { doFlush, calls, blockNext, unblock } = makeControllableFlush() + const fc = new FlushController(doFlush) + fc.setCardMessageReady(true) + + blockNext() + const p1 = fc.flush() + await sleep(10) + + // API 进行中收到新的 update 请求 + await fc.throttledUpdate(10) + + unblock() + await p1 + await sleep(30) + + // 第一次 flush + 冲突补刷 + expect(calls.filter((c) => c === 'flush-end').length).toBe(2) + }) +}) + +// --------------------------------------------------------------------------- +// Long gap batching +// --------------------------------------------------------------------------- + +describe('FlushController: 长间隔批量', () => { + it('elapsed > LONG_GAP_THRESHOLD_MS 时延迟 BATCH_AFTER_GAP_MS 再 flush', async () => { + let count = 0 + let flushAtMs = 0 + const start = Date.now() + const fc = new FlushController(async () => { + count += 1 + flushAtMs = Date.now() - start + }) + fc.setCardMessageReady(true) + + // 等到 elapsed > 2000ms + await sleep(THROTTLE.LONG_GAP_THRESHOLD_MS + 50) + const callAt = Date.now() - start + + await fc.throttledUpdate(THROTTLE.CARDKIT_MS) + // throttledUpdate 同步阶段不应立即 flush(因为走批量分支) + expect(count).toBe(0) + + // 等 BATCH_AFTER_GAP_MS + 余量 + await sleep(THROTTLE.BATCH_AFTER_GAP_MS + 50) + expect(count).toBe(1) + // 实际 flush 时刻至少比 throttledUpdate 调用晚 300ms + expect(flushAtMs - callAt).toBeGreaterThanOrEqual(THROTTLE.BATCH_AFTER_GAP_MS - 20) + }) +}) + +// --------------------------------------------------------------------------- +// waitForFlush +// --------------------------------------------------------------------------- + +describe('FlushController: waitForFlush', () => { + it('没在 flush 时立即返回', async () => { + const fc = new FlushController(async () => {}) + fc.setCardMessageReady(true) + const start = Date.now() + await fc.waitForFlush() + expect(Date.now() - start).toBeLessThan(10) + }) + + it('有 flush 在跑时等它结束', async () => { + const { doFlush, blockNext, unblock } = makeControllableFlush() + const fc = new FlushController(doFlush) + fc.setCardMessageReady(true) + + blockNext() + const p1 = fc.flush() + await sleep(10) + + let resolved = false + const waiter = fc.waitForFlush().then(() => { + resolved = true + }) + await sleep(20) + expect(resolved).toBe(false) + + unblock() + await p1 + await waiter + expect(resolved).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// 常量合理性 +// --------------------------------------------------------------------------- + +describe('FlushController: THROTTLE 常量', () => { + it('CARDKIT_MS=100, PATCH_MS=1500', () => { + expect(THROTTLE.CARDKIT_MS).toBe(100) + expect(THROTTLE.PATCH_MS).toBe(1500) + }) + it('LONG_GAP_THRESHOLD_MS=2000, BATCH_AFTER_GAP_MS=300', () => { + expect(THROTTLE.LONG_GAP_THRESHOLD_MS).toBe(2000) + expect(THROTTLE.BATCH_AFTER_GAP_MS).toBe(300) + }) +}) diff --git a/adapters/feishu/__tests__/markdown-style.test.ts b/adapters/feishu/__tests__/markdown-style.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..26fcf1d8428ee2084416e7687f632a31301bf34e --- /dev/null +++ b/adapters/feishu/__tests__/markdown-style.test.ts @@ -0,0 +1,353 @@ +/** + * markdown-style 单元测试 + * + * 覆盖: + * - 标题降级 (H1~H3 workaround) + * - 代码块保护 + * - 空行压缩 + * - Schema 2.0: 连续标题/表格/代码块
间距 + * - stripInvalidImageKeys + * - sanitizeTextForCard (表格数限制) + * - findMarkdownTablesOutsideCodeBlocks + */ + +import { describe, it, expect } from 'bun:test' +import { + optimizeMarkdownForFeishu, + sanitizeTextForCard, + findMarkdownTablesOutsideCodeBlocks, + FEISHU_CARD_TABLE_LIMIT, +} from '../markdown-style.js' + +// 默认 cardVersion=2 的 shortcut +const opt = (text: string, v?: number) => optimizeMarkdownForFeishu(text, v) + +// --------------------------------------------------------------------------- +// 标题降级 +// --------------------------------------------------------------------------- + +describe('optimizeMarkdownForFeishu: 标题降级', () => { + it('H1 → H4 (cardVersion=1 简化检查)', () => { + expect(opt('# Title', 1)).toBe('#### Title') + }) + + it('H2 → H5 (cardVersion=1)', () => { + expect(opt('## Title', 1)).toBe('##### Title') + }) + + it('H3 → H5 (cardVersion=1)', () => { + expect(opt('### Title', 1)).toBe('##### Title') + }) + + it('混合 H1+H2+H3 全部降级 (cardVersion=1)', () => { + expect(opt('# H1\n## H2\n### H3', 1)).toBe('#### H1\n##### H2\n##### H3') + }) + + it('纯 H4 文档不触发降级 (cardVersion=1)', () => { + // 触发条件: 原文必须有 H1~H3 + expect(opt('#### Already H4', 1)).toBe('#### Already H4') + }) + + it('同时存在 H1 和 H4: H1→H4, 原 H4 → H5 (cardVersion=1)', () => { + expect(opt('# Top\n#### Sub', 1)).toBe('#### Top\n##### Sub') + }) + + it('# 后必须有空格才算标题', () => { + expect(opt('#notaheading', 1)).toBe('#notaheading') + }) + + it('顺序保证: # 降成 #### 后不会被 #{2,6} 再次吃成 #####', () => { + // openclaw-lark 源码里的关键注释:顺序不能颠倒 + expect(opt('# Top', 1)).toBe('#### Top') + }) + + it('无标题文本原样返回', () => { + expect(opt('just plain text', 1)).toBe('just plain text') + }) + + it('默认 cardVersion=2 下也能正确降级标题', () => { + const out = opt('# Title') + expect(out).toContain('#### Title') + expect(out).not.toMatch(/^# Title$/m) + }) +}) + +// --------------------------------------------------------------------------- +// 代码块保护 +// --------------------------------------------------------------------------- + +describe('optimizeMarkdownForFeishu: 代码块保护', () => { + it('代码块内的 # 不被降级 (cardVersion=1)', () => { + const input = '```\n# not a heading\n## also not\n```' + expect(opt(input, 1)).toBe(input) + }) + + it('外部 H1 降级,代码块内 # 保持 (cardVersion=1)', () => { + const input = '# Real heading\n\n```\n# inside code\n```' + expect(opt(input, 1)).toBe('#### Real heading\n\n```\n# inside code\n```') + }) + + it('语言标记的 fenced 代码块也受保护 (cardVersion=1)', () => { + const input = '## Section\n\n```python\n# python comment\n### not a heading\n```' + expect(opt(input, 1)).toBe('##### Section\n\n```python\n# python comment\n### not a heading\n```') + }) + + it('多个代码块按顺序保护与还原 (cardVersion=1)', () => { + const input = '# A\n```\n# b1\n```\n## C\n```\n### b2\n```' + expect(opt(input, 1)).toBe('#### A\n```\n# b1\n```\n##### C\n```\n### b2\n```') + }) + + it('默认 cardVersion=2 下代码块内 # 仍受保护(语义断言)', () => { + const out = opt('# Heading\n\n```\n# inside\n```') + expect(out).toContain('#### Heading') // 外部 H1 降级 + expect(out).toContain('# inside') // 代码块内保留 + expect(out).toContain('```') // fence 保留 + }) +}) + +// --------------------------------------------------------------------------- +// 空行压缩 +// --------------------------------------------------------------------------- + +describe('optimizeMarkdownForFeishu: 空行压缩', () => { + it('3 个换行 → 2 个 (cardVersion=1)', () => { + expect(opt('line1\n\n\nline2', 1)).toBe('line1\n\nline2') + }) + + it('5 个换行 → 2 个 (cardVersion=1)', () => { + expect(opt('line1\n\n\n\n\nline2', 1)).toBe('line1\n\nline2') + }) + + it('2 个换行保留 (cardVersion=1)', () => { + expect(opt('line1\n\nline2', 1)).toBe('line1\n\nline2') + }) + + it('代码块内部连续换行被保留 (cardVersion=1)', () => { + const input = '# Title\n\n```\nline1\n\n\nline2\n```' + expect(opt(input, 1)).toBe('#### Title\n\n```\nline1\n\n\nline2\n```') + }) +}) + +// --------------------------------------------------------------------------- +// Schema 2.0:
间距 +// --------------------------------------------------------------------------- + +describe('optimizeMarkdownForFeishu: Schema 2.0
间距', () => { + it('cardVersion=2 默认在代码块前后加
', () => { + const out = opt('text\n\n```\ncode\n```') + // 代码块前后应包裹
+ expect(out).toContain('
\n```') + expect(out).toContain('```\n
') + }) + + it('cardVersion=1 代码块前后不加
', () => { + const out = opt('text\n\n```\ncode\n```', 1) + expect(out).not.toContain('
') + }) + + it('cardVersion=2 连续标题之间加
', () => { + const out = opt('# A\n# B') + // H1 降级为 H4,之间插入
+ expect(out).toMatch(/#### A\n
\n#### B/) + }) + + it('cardVersion=2 表格前后加
', () => { + const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after' + const out = opt(input) + // 表格前:
紧贴文本行(规则 3e 压缩多余空行) + expect(out).toMatch(/text before\n
\n\| col1/) + // 表格后:
跟两个换行到下一段文本 + expect(out).toMatch(/\| v1 \| v2 \|\n
\n\ntext after/) + }) + + it('cardVersion=1 表格前后不加
', () => { + const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after' + const out = opt(input, 1) + expect(out).not.toContain('
') + }) + + it('代码块内的 | 不被当表格处理 (Schema 2.0)', () => { + const input = '```\n| in code | not a table |\n|---|---|\n```' + const out = opt(input) + // 代码块本体应完整保留 + expect(out).toContain('| in code | not a table |') + // 代码块外应该没有出现表格 br 标记(因为代码块内不算表格) + // 代码块本身会被
包裹(Schema 2.0)但不会在 | 周围单独加
+ expect(out).toMatch(/
\n```\n\| in code/) + }) +}) + +// --------------------------------------------------------------------------- +// stripInvalidImageKeys +// --------------------------------------------------------------------------- + +describe('optimizeMarkdownForFeishu: stripInvalidImageKeys', () => { + it('img_* 图片 key 保留', () => { + const out = opt('前缀 ![alt](img_abc123) 后缀') + expect(out).toContain('![alt](img_abc123)') + }) + + it('http:// URL 图片被删除', () => { + const out = opt('前缀 ![alt](http://example.com/img.png) 后缀') + expect(out).toBe('前缀 后缀') + }) + + it('https:// URL 图片被删除', () => { + const out = opt('![a](https://x.y/z.jpg)') + expect(out).toBe('') + }) + + it('本地路径被删除', () => { + const out = opt('![a](/Users/me/pic.png)') + expect(out).toBe('') + }) + + it('无图片文本原样', () => { + expect(opt('no images here', 1)).toBe('no images here') + }) + + it('混合: img_ 保留,URL 删除', () => { + const out = opt('![keep](img_good) 和 ![drop](http://bad.com/x.png)') + expect(out).toContain('![keep](img_good)') + expect(out).not.toContain('bad.com') + expect(out).not.toContain('![drop]') + }) +}) + +// --------------------------------------------------------------------------- +// findMarkdownTablesOutsideCodeBlocks +// --------------------------------------------------------------------------- + +describe('findMarkdownTablesOutsideCodeBlocks', () => { + it('识别单张表格', () => { + const text = '| a | b |\n|---|---|\n| 1 | 2 |' + const matches = findMarkdownTablesOutsideCodeBlocks(text) + expect(matches.length).toBe(1) + expect(matches[0]!.raw).toContain('| a | b |') + }) + + it('识别多张表格', () => { + const text = + '| a | b |\n|---|---|\n| 1 | 2 |\n\ntext\n\n| x | y |\n|---|---|\n| 3 | 4 |' + const matches = findMarkdownTablesOutsideCodeBlocks(text) + expect(matches.length).toBe(2) + }) + + it('代码块内的 | 不被算作表格', () => { + const text = '```\n| in | code |\n|---|---|\n| 1 | 2 |\n```' + const matches = findMarkdownTablesOutsideCodeBlocks(text) + expect(matches.length).toBe(0) + }) + + it('代码块 + 外部表格: 只识别外部的', () => { + const text = + '```\n| in | code |\n|---|---|\n| 1 | 2 |\n```\n\n| real | table |\n|---|---|\n| a | b |' + const matches = findMarkdownTablesOutsideCodeBlocks(text) + expect(matches.length).toBe(1) + expect(matches[0]!.raw).toContain('real') + }) + + it('无表格文本返回空数组', () => { + expect(findMarkdownTablesOutsideCodeBlocks('just text').length).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// sanitizeTextForCard +// --------------------------------------------------------------------------- + +describe('sanitizeTextForCard: 表格数量限制', () => { + function makeTable(label: string): string { + return `| ${label} h1 | h2 |\n|---|---|\n| v1 | v2 |` + } + + it('表格数 ≤ 3 时原样返回', () => { + const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n') + expect(sanitizeTextForCard(text)).toBe(text) + }) + + it('恰好 3 张表格原样返回', () => { + const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n') + const matches = findMarkdownTablesOutsideCodeBlocks(text) + expect(matches.length).toBe(3) + expect(sanitizeTextForCard(text)).toBe(text) + }) + + it('4 张表格: 前 3 张保留,第 4 张包裹成 code block', () => { + const text = [makeTable('A'), makeTable('B'), makeTable('C'), makeTable('D')].join('\n\n') + const out = sanitizeTextForCard(text) + // 前 3 张表格原样 + expect(out).toContain(makeTable('A')) + expect(out).toContain(makeTable('B')) + expect(out).toContain(makeTable('C')) + // 第 4 张被包裹 + expect(out).toContain('```\n' + makeTable('D') + '\n```') + }) + + it('自定义 limit=1: 第 1 张保留,之后全部包裹', () => { + const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n') + const out = sanitizeTextForCard(text, 1) + expect(out).toContain(makeTable('A')) + expect(out).toContain('```\n' + makeTable('B') + '\n```') + expect(out).toContain('```\n' + makeTable('C') + '\n```') + }) + + it('limit=0: 全部包裹', () => { + const text = makeTable('Solo') + const out = sanitizeTextForCard(text, 0) + expect(out).toContain('```\n' + makeTable('Solo') + '\n```') + }) + + it('无表格原样返回', () => { + expect(sanitizeTextForCard('no tables here')).toBe('no tables here') + }) + + it('FEISHU_CARD_TABLE_LIMIT 默认值 = 3', () => { + expect(FEISHU_CARD_TABLE_LIMIT).toBe(3) + }) +}) + +// --------------------------------------------------------------------------- +// 边界与真实场景 +// --------------------------------------------------------------------------- + +describe('optimizeMarkdownForFeishu: 边界与真实场景', () => { + it('screenshot 里的 OpenCutSkill 项目结构报告', () => { + const input = `## OpenCutSkill 项目架构概览 + +### 1. 项目定位 + +Screen Studio 视频自动剪辑工具。 + +### 2. 模块结构 + +\`\`\` +opencutskill/ +├── cli/ +├── core/ +└── tests/ +\`\`\`` + const out = opt(input) + // 所有 H2~H3 应被降级为 H5 + expect(out).toContain('##### OpenCutSkill 项目架构概览') + expect(out).toContain('##### 1. 项目定位') + expect(out).toContain('##### 2. 模块结构') + // 代码块内容原封不动 + expect(out).toContain('opencutskill/') + expect(out).toContain('├── cli/') + // 原始 ## 字面量不残留 + expect(out).not.toMatch(/^## OpenCutSkill/m) + expect(out).not.toMatch(/^### 1\./m) + // 代码块前后有
(Schema 2.0 默认) + expect(out).toContain('
\n```') + expect(out).toContain('```\n
') + }) + + it('异常输入 fallback 到原文不抛错', () => { + expect(() => opt('\u0000\uFFFF```unclosed')).not.toThrow() + }) + + it('空字符串返回空字符串', () => { + expect(opt('')).toBe('') + }) +}) diff --git a/adapters/feishu/__tests__/media.test.ts b/adapters/feishu/__tests__/media.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b350a99c8583c06fb60e77599c0062d5126c783 --- /dev/null +++ b/adapters/feishu/__tests__/media.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import * as os from 'node:os' +import { FeishuMediaService } from '../media.js' +import { AttachmentStore } from '../../common/attachment/attachment-store.js' + +function makeMockClient() { + return { + im: { + messageResource: { + get: mock(async () => ({ + // node-sdk returns an object with a `.writeFile(path)` helper + // that dumps the underlying stream. We fake that here. + writeFile: async (target: string) => { + await fs.writeFile(target, Buffer.from('DOWNLOADED')) + }, + })), + }, + image: { + create: mock(async (_req: any) => ({ + data: { image_key: 'img_fake_123' }, + })), + }, + file: { + create: mock(async (_req: any) => ({ + data: { file_key: 'file_fake_456' }, + })), + }, + message: { + create: mock(async (_req: any) => ({ + data: { message_id: 'om_fake' }, + })), + }, + }, + } +} + +let tmpRoot: string + +beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'feishu-media-test-')) +}) + +afterEach(async () => { + await fs.rm(tmpRoot, { recursive: true, force: true }) +}) + +describe('FeishuMediaService', () => { + it('downloadResource writes a local file and returns LocalAttachment', async () => { + const client = makeMockClient() + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const svc = new FeishuMediaService(client as any, store) + const local = await svc.downloadResource({ + messageId: 'om_msg_1', + fileKey: 'img_key_1', + kind: 'image', + fileName: 'cat.png', + sessionId: 'sess-1', + }) + expect(local.kind).toBe('image') + expect(local.name).toBe('cat.png') + expect(local.size).toBe('DOWNLOADED'.length) + expect(local.path).toContain(path.join('feishu', 'sess-1')) + const onDisk = await fs.readFile(local.path) + expect(onDisk.toString()).toBe('DOWNLOADED') + expect(client.im.messageResource.get).toHaveBeenCalledTimes(1) + const call = (client.im.messageResource.get as any).mock.calls[0][0] + expect(call.path.message_id).toBe('om_msg_1') + expect(call.path.file_key).toBe('img_key_1') + expect(call.params.type).toBe('image') + }) + + it('uploadImage returns an image_key and sends the buffer through', async () => { + const client = makeMockClient() + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const svc = new FeishuMediaService(client as any, store) + const key = await svc.uploadImage(Buffer.from('PNGDATA'), 'image/png') + expect(key).toBe('img_fake_123') + expect(client.im.image.create).toHaveBeenCalledTimes(1) + const call = (client.im.image.create as any).mock.calls[0][0] + expect(call.data.image_type).toBe('message') + expect(call.data.image).toBeDefined() + }) + + it('uploadFile returns a file_key and uses stream file_type mapping', async () => { + const client = makeMockClient() + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const svc = new FeishuMediaService(client as any, store) + const key = await svc.uploadFile(Buffer.from('PDFDATA'), 'report.pdf') + expect(key).toBe('file_fake_456') + const call = (client.im.file.create as any).mock.calls[0][0] + expect(call.data.file_name).toBe('report.pdf') + expect(call.data.file_type).toBe('pdf') + }) + + it('sendImageMessage posts msg_type=image', async () => { + const client = makeMockClient() + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const svc = new FeishuMediaService(client as any, store) + await svc.sendImageMessage('oc_chat_1', 'img_fake_123') + const call = (client.im.message.create as any).mock.calls[0][0] + expect(call.params.receive_id_type).toBe('chat_id') + expect(call.data.receive_id).toBe('oc_chat_1') + expect(call.data.msg_type).toBe('image') + const content = JSON.parse(call.data.content) + expect(content.image_key).toBe('img_fake_123') + }) + + it('sendFileMessage posts msg_type=file', async () => { + const client = makeMockClient() + const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 }) + const svc = new FeishuMediaService(client as any, store) + await svc.sendFileMessage('oc_chat_1', 'file_fake_456') + const call = (client.im.message.create as any).mock.calls[0][0] + expect(call.data.msg_type).toBe('file') + const content = JSON.parse(call.data.content) + expect(content.file_key).toBe('file_fake_456') + }) +}) diff --git a/adapters/feishu/__tests__/streaming-card.test.ts b/adapters/feishu/__tests__/streaming-card.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fbb090aec73e02f4cfbd8b559857f55366fee634 --- /dev/null +++ b/adapters/feishu/__tests__/streaming-card.test.ts @@ -0,0 +1,947 @@ +/** + * StreamingCard 生命周期测试 + * + * 用 mock Lark client 覆盖: + * - ensureCreated: 成功路径 / 降级路径 + * - appendText: 累积 + 触发 throttled flush + * - finalize: settings(false) + update 顺序、sequence 单调递增 + * - abort: 渲染错误卡片 + * - 230020 → 跳帧 + * - 230099 table limit → 禁用流式,finalize 时仍走 CardKit + * - 纯 patch fallback 路径 + */ + +import { describe, it, expect, beforeEach } from 'bun:test' +import { + StreamingCard, + buildInitialStreamingCard, + buildRenderedCard, + buildErrorCard, +} from '../streaming-card.js' +import { STREAMING_ELEMENT_ID } from '../cardkit.js' + +// --------------------------------------------------------------------------- +// Mock client +// --------------------------------------------------------------------------- + +type ApiCall = { api: string; args: any } + +type MockBehavior = { + 'card.create'?: any | ((args: any) => any) + 'card.settings'?: any | ((args: any) => any) + 'card.update'?: any | ((args: any) => any) + 'cardElement.content'?: any | ((args: any, callIdx: number) => any) + 'im.message.create'?: any | ((args: any) => any) + 'im.message.reply'?: any | ((args: any) => any) + 'im.message.patch'?: any | ((args: any, callIdx: number) => any) +} + +function makeMockClient(behavior: MockBehavior = {}) { + const calls: ApiCall[] = [] + let contentCallIdx = 0 + let patchCallIdx = 0 + + function handle(api: string, resp: any, args: any, idx?: number): any { + calls.push({ api, args }) + if (typeof resp === 'function') return resp(args, idx ?? 0) + return resp + } + + const client: any = { + cardkit: { + v1: { + card: { + create: async (args: any) => + handle('cardkit.v1.card.create', behavior['card.create'] ?? { + code: 0, data: { card_id: 'ck_default' }, + }, args), + settings: async (args: any) => + handle('cardkit.v1.card.settings', behavior['card.settings'] ?? { code: 0 }, args), + update: async (args: any) => + handle('cardkit.v1.card.update', behavior['card.update'] ?? { code: 0 }, args), + }, + cardElement: { + content: async (args: any) => { + const idx = contentCallIdx++ + return handle('cardkit.v1.cardElement.content', + behavior['cardElement.content'] ?? { code: 0 }, args, idx) + }, + }, + }, + }, + im: { + message: { + create: async (args: any) => + handle('im.message.create', behavior['im.message.create'] ?? { + data: { message_id: 'om_default' }, + }, args), + reply: async (args: any) => + handle('im.message.reply', behavior['im.message.reply'] ?? { + data: { message_id: 'om_reply_default' }, + }, args), + patch: async (args: any) => { + const idx = patchCallIdx++ + return handle('im.message.patch', behavior['im.message.patch'] ?? { code: 0 }, args, idx) + }, + }, + }, + } + return { client, calls } +} + +async function sleep(ms: number) { + await new Promise((r) => setTimeout(r, ms)) +} + +// --------------------------------------------------------------------------- +// Card JSON builders +// --------------------------------------------------------------------------- + +describe('buildInitialStreamingCard', () => { + it('Schema 2.0 + streaming_mode + element_id', () => { + const card = buildInitialStreamingCard() as any + expect(card.schema).toBe('2.0') + expect(card.config.streaming_mode).toBe(true) + // 唯一元素:streaming_content,初始内容为 loading 提示 + const elements = card.body.elements as any[] + expect(elements.length).toBe(1) + const streaming = elements[0] + expect(streaming.tag).toBe('markdown') + expect(streaming.content).toContain('正在思考中') + expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID) + }) +}) + +describe('buildRenderedCard', () => { + it('Schema 2.0, 无 streaming_mode, 单 markdown 元素', () => { + const card = buildRenderedCard('hello world') as any + expect(card.schema).toBe('2.0') + expect(card.config.streaming_mode).toBeUndefined() + expect(card.body.elements.length).toBe(1) + const el = card.body.elements[0] + expect(el.tag).toBe('markdown') + expect(el.content).toBe('hello world') + // 最终卡无需 element_id + expect(el.element_id).toBeUndefined() + }) + + it('空字符串保底为单空格', () => { + const card = buildRenderedCard('') as any + expect(card.body.elements[0].content).toBe(' ') + }) +}) + +describe('buildErrorCard', () => { + it('红色 header + markdown body', () => { + const card = buildErrorCard('oops') as any + expect((card.header as any).template).toBe('red') + expect((card.header as any).title.content).toContain('出错') + expect(card.body.elements[0].content).toBe('oops') + }) +}) + +// --------------------------------------------------------------------------- +// StreamingCard lifecycle +// --------------------------------------------------------------------------- + +describe('StreamingCard: ensureCreated (CardKit 主路径)', () => { + it('依次调用 card.create + im.message.create,sequence=1', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_main_1' } }, + 'im.message.create': { data: { message_id: 'om_main_1' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'oc_chat_1' }) + await sc.ensureCreated() + + expect(sc._getPhase()).toBe('streaming') + expect(sc._getCardId()).toBe('ck_main_1') + expect(sc._getMessageId()).toBe('om_main_1') + expect(sc._getSequence()).toBe(1) + expect(sc._isCardKitStreamActive()).toBe(true) + + expect(calls[0]!.api).toBe('cardkit.v1.card.create') + expect(calls[1]!.api).toBe('im.message.create') + + // 初始卡 JSON 包含 streaming_mode 和 element_id + const cardJson = JSON.parse(calls[0]!.args.data.data) + expect(cardJson.schema).toBe('2.0') + expect(cardJson.config.streaming_mode).toBe(true) + // 唯一元素即 streaming_content + expect(cardJson.body.elements[0].element_id).toBe(STREAMING_ELEMENT_ID) + + // IM message 引用 card_id + const content = JSON.parse(calls[1]!.args.data.content) + expect(content).toEqual({ type: 'card', data: { card_id: 'ck_main_1' } }) + }) + + it('幂等: 重复调用 ensureCreated 不重复创建', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_1' } }, + 'im.message.create': { data: { message_id: 'om_1' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + await sc.ensureCreated() + await sc.ensureCreated() + // 只一次 create + 一次 send + const createCalls = calls.filter((c) => c.api === 'cardkit.v1.card.create') + const sendCalls = calls.filter((c) => c.api === 'im.message.create') + expect(createCalls.length).toBe(1) + expect(sendCalls.length).toBe(1) + }) + + it('replyToMessageId 走 im.message.reply 而非 create', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.reply': { data: { message_id: 'om_reply' } }, + }) + const sc = new StreamingCard({ + larkClient: client, + chatId: 'c', + replyToMessageId: 'om_parent', + }) + await sc.ensureCreated() + expect(calls.some((c) => c.api === 'im.message.reply')).toBe(true) + expect(calls.some((c) => c.api === 'im.message.create')).toBe(false) + expect(sc._getMessageId()).toBe('om_reply') + }) +}) + +describe('StreamingCard: ensureCreated (fallback 降级路径)', () => { + it('CardKit create 失败 → 直发 Schema 2.0 卡 + patch 模式', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 99991672, msg: 'permission denied' }, + 'im.message.create': { data: { message_id: 'om_fb' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + expect(sc._getPhase()).toBe('streaming') + expect(sc._getCardId()).toBeNull() + expect(sc._getMessageId()).toBe('om_fb') + expect(sc._isCardKitStreamActive()).toBe(false) + + // fallback 发送的是 Schema 2.0 interactive 卡 + const createCall = calls.find((c) => c.api === 'im.message.create') + expect(createCall).toBeDefined() + expect(createCall!.args.data.msg_type).toBe('interactive') + const cardContent = JSON.parse(createCall!.args.data.content) + expect(cardContent.schema).toBe('2.0') + }) + + it('CardKit send 失败(create 成功但 im.message.create 失败)也能降级', async () => { + let sendCallCount = 0 + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': () => { + sendCallCount++ + if (sendCallCount === 1) throw new Error('send failed') + return { data: { message_id: 'om_fb2' } } + }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + expect(sc._getPhase()).toBe('streaming') + expect(sc._getCardId()).toBeNull() + expect(sc._getMessageId()).toBe('om_fb2') + }) + + it('降级发送也失败 → aborted + throw', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 99991672 }, + 'im.message.create': () => { + throw new Error('really broken') + }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await expect(sc.ensureCreated()).rejects.toThrow() + expect(sc._getPhase()).toBe('aborted') + }) +}) + +// --------------------------------------------------------------------------- +// appendText + flush +// --------------------------------------------------------------------------- + +describe('StreamingCard: appendText + flush', () => { + it('accumulated 文本写入 cardElement.content,sequence 单调递增', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_stream' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + // 第一次 appendText 进入节流窗口(刚 ready,lastUpdateTime 还新) + sc.appendText('Hello ') + sc.appendText('world') + + // 节流窗口 100ms + 余量 + await sleep(150) + + const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + expect(contentCalls.length).toBeGreaterThan(0) + // 最后一次 flush 的内容应包含完整累积文本 + const lastCall = contentCalls[contentCalls.length - 1]! + expect(lastCall.args.data.content).toContain('Hello world') + expect(lastCall.args.path.element_id).toBe(STREAMING_ELEMENT_ID) + // sequence 严格单调递增 + const seqs = contentCalls.map((c) => c.args.data.sequence) + for (let i = 1; i < seqs.length; i++) { + expect(seqs[i]).toBeGreaterThan(seqs[i - 1]!) + } + }) + + it('内容未变化时不重复 flush(基于 lastFlushedText 对比)', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.appendText('same') + await sleep(150) + + // 强制再跑一次 flush(无新文本) + await sc._getFlushController().flush() + + const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + // 应该只有一次 content 调用 + expect(contentCalls.length).toBe(1) + }) + + it('completed 之后的 appendText 被忽略', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + await sc.finalize() + sc.appendText('ignored') + expect(sc._getAccumulatedText()).toBe('') + }) +}) + +// --------------------------------------------------------------------------- +// finalize +// --------------------------------------------------------------------------- + +describe('StreamingCard: finalize', () => { + it('CardKit 路径: settings(false) + card.update,sequence 连续递增', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_final' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + sc.appendText('# Title\n\nBody') + await sleep(150) + const contentSeqs = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .map((c) => c.args.data.sequence) + const lastContentSeq = contentSeqs[contentSeqs.length - 1] ?? 1 + + await sc.finalize() + + expect(sc._getPhase()).toBe('completed') + + const settingsCalls = calls.filter((c) => c.api === 'cardkit.v1.card.settings') + const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update') + expect(settingsCalls.length).toBe(1) + expect(updateCalls.length).toBe(1) + + const settingsSeq = settingsCalls[0]!.args.data.sequence + const updateSeq = updateCalls[0]!.args.data.sequence + expect(settingsSeq).toBeGreaterThan(lastContentSeq) + expect(updateSeq).toBeGreaterThan(settingsSeq) + + // settings 关闭 streaming_mode + const settings = JSON.parse(settingsCalls[0]!.args.data.settings) + expect(settings.streaming_mode).toBe(false) + + // update 卡内容是预处理后的 markdown + const finalCardJson = JSON.parse(updateCalls[0]!.args.data.card.data) + const finalContent = finalCardJson.body.elements[0].content + // H1 被降级为 H4 + expect(finalContent).toContain('#### Title') + expect(finalContent).toContain('Body') + }) + + it('Fallback 路径: im.message.patch 发完整渲染卡', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 99991672 }, + 'im.message.create': { data: { message_id: 'om_fb' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + sc.appendText('## Heading\n\nContent') + await sleep(1600) // 等 PATCH_MS 窗口 + await sc.finalize() + + const patchCalls = calls.filter((c) => c.api === 'im.message.patch') + expect(patchCalls.length).toBeGreaterThan(0) + // 最后一次 patch 是 finalize 的(full final card) + const lastPatch = patchCalls[patchCalls.length - 1]! + const finalCard = JSON.parse(lastPatch.args.data.content) + const finalContent = finalCard.body.elements[0].content + // ## → ##### 降级 + expect(finalContent).toContain('##### Heading') + }) + + it('完全 idle 时 finalize 直接标记 completed 不抛错', async () => { + const { client } = makeMockClient() + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.finalize() + expect(sc._getPhase()).toBe('completed') + }) + + it('finalize 只保留 answerText,丢弃 reasoning + toolSteps', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_term' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + // 同时塞入三种内容 + sc.appendReasoning('Let me think about this problem carefully...') + sc.startTool('tu_1', 'Read') + sc.completeTool('tu_1', 'Read') + sc.appendText('## 答复\n\n这是最终答复正文。') + await sleep(150) + + // 流式中间帧应该包含 reasoning + tools + answer 全套 + const lastMidFrame = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + expect(lastMidFrame).toContain('思考中') + expect(lastMidFrame).toContain('Read') + expect(lastMidFrame).toContain('最终答复正文') + + await sc.finalize() + + // finalize 用的是 card.update,把整张卡换成只有 answer 的版本 + const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()! + const finalCardJson = JSON.parse(updateCall.args.data.card.data) + const finalContent = finalCardJson.body.elements[0].content as string + + expect(finalContent).toContain('最终答复正文') + // H2 → 降级 H5 + expect(finalContent).toContain('##### 答复') + // reasoning + tools 都不应该出现在终态 + expect(finalContent).not.toContain('思考中') + expect(finalContent).not.toContain('think about this problem') + expect(finalContent).not.toContain('Read') + expect(finalContent).not.toContain('🛠️') + expect(finalContent).not.toContain('💭') + }) + + it('finalize 边界: 没有 answerText 时退到组合渲染(保留推理)', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_no_answer' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + // 只有推理,没有 appendText —— 异常 case 但要可控降级 + sc.appendReasoning('I was thinking but never produced an answer.') + await sleep(150) + + await sc.finalize() + const updateCall = calls.filter((c) => c.api === 'cardkit.v1.card.update').pop()! + const finalContent = JSON.parse(updateCall.args.data.card.data).body.elements[0].content as string + // 至少能看到推理内容 + expect(finalContent).toContain('thinking') + }) + + it('finalize 失败不抛出', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + 'card.settings': () => { + throw new Error('settings exploded') + }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + sc.appendText('text') + await sleep(150) + // finalize 内部捕获错误不 rethrow + await sc.finalize() + expect(sc._getPhase()).toBe('completed') + }) +}) + +// --------------------------------------------------------------------------- +// Rate limit + table limit +// --------------------------------------------------------------------------- + +describe('StreamingCard: 错误处理', () => { + it('230020 rate limit → 跳帧,后续 flush 继续', async () => { + let callIdx = 0 + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + 'cardElement.content': () => { + const i = callIdx++ + if (i === 0) { + const err: any = new Error('rate limit') + err.code = 230020 + throw err + } + return { code: 0 } + }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + sc.appendText('first') + await sleep(150) + // 第一次被限流 + sc.appendText(' second') + await sleep(150) + // 第二次应能成功 + + // CardKit 仍然 active(没降级) + expect(sc._isCardKitStreamActive()).toBe(true) + const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + expect(contentCalls.length).toBeGreaterThanOrEqual(2) + }) + + it('230099 table limit → 禁用流式但 cardId 保留,finalize 仍走 CardKit', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_tbl' } }, + 'im.message.create': { data: { message_id: 'om' } }, + 'cardElement.content': () => { + const err: any = new Error('content failed') + err.code = 230099 + err.msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ' + throw err + }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + sc.appendText('some content') + await sleep(150) + expect(sc._isCardKitStreamActive()).toBe(false) + expect(sc._getCardId()).toBe('ck_tbl') // card_id 保留 + + await sc.finalize() + // finalize 仍然走 CardKit 的 settings + update(cardId 还在) + expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true) + expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true) + // 不走 patch + expect(calls.some((c) => c.api === 'im.message.patch')).toBe(false) + }) + + it('CardKit 中间帧请求挂住时不会阻塞 message_complete 收尾', async () => { + const previousTimeout = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS + process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = '20' + try { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_hung' } }, + 'im.message.create': { data: { message_id: 'om' } }, + 'cardElement.content': () => new Promise(() => {}), + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.appendText('partial text') + await sleep(60) + + const completed = await Promise.race([ + sc.finalize().then(() => true), + sleep(250).then(() => false), + ]) + + expect(completed).toBe(true) + expect(sc._getPhase()).toBe('completed') + expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true) + expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true) + } finally { + if (previousTimeout === undefined) { + delete process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS + } else { + process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS = previousTimeout + } + } + }) +}) + +// --------------------------------------------------------------------------- +// abort +// --------------------------------------------------------------------------- + +describe('StreamingCard: abort', () => { + it('CardKit 路径: 渲染错误卡并关闭流式', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_err' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + sc.appendText('partial...') + await sleep(150) + + await sc.abort(new Error('something went wrong')) + expect(sc._getPhase()).toBe('aborted') + + const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update') + expect(updateCalls.length).toBeGreaterThan(0) + const errCard = JSON.parse(updateCalls[updateCalls.length - 1]!.args.data.card.data) + expect(errCard.header.template).toBe('red') + expect(errCard.body.elements[0].content).toContain('something went wrong') + // 保留已累积的部分文本 + expect(errCard.body.elements[0].content).toContain('partial...') + }) + + it('idle 阶段 abort 不抛错', async () => { + const { client } = makeMockClient() + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.abort(new Error('before any card')) + expect(sc._getPhase()).toBe('aborted') + }) +}) + +// --------------------------------------------------------------------------- +// Reasoning / tool use rendering +// --------------------------------------------------------------------------- + +describe('StreamingCard: appendReasoning', () => { + it('累积 thinking delta 并渲染在卡片中(plain markdown,不用 blockquote)', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_think' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.appendReasoning('Analyzing the problem. ') + sc.appendReasoning('Let me check file A.') + await sleep(150) + + const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + expect(contentCalls.length).toBeGreaterThan(0) + const last = contentCalls[contentCalls.length - 1]! + expect(last.args.data.content).toContain('💭') + expect(last.args.data.content).toContain('思考中') + expect(last.args.data.content).toContain('Analyzing the problem.') + expect(last.args.data.content).toContain('Let me check file A.') + // 没有 blockquote `>` 前缀 —— 这是新格式的关键 + expect(last.args.data.content).not.toContain('> Analyzing') + // 没有 appendText → 不应有普通正文 + expect(sc._getAccumulatedReasoning()).toContain('Analyzing') + expect(sc._getAccumulatedText()).toBe('') + }) + + it('completed 之后 appendReasoning 被忽略', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + await sc.finalize() + sc.appendReasoning('too late') + expect(sc._getAccumulatedReasoning()).toBe('') + }) +}) + +describe('StreamingCard: startTool / completeTool', () => { + it('startTool 压入 running 步骤,completeTool 翻到 done', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_tool' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.startTool('tu_1', 'Read') + await sleep(150) + let steps = sc._getToolSteps() + expect(steps.length).toBe(1) + expect(steps[0]!.name).toBe('Read') + expect(steps[0]!.status).toBe('running') + + // 卡片也应显示 "🛠️ ⚙️ Read"(inline 形式) + const runningContent = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .map((c) => c.args.data.content) + .join('\n') + expect(runningContent).toContain('⚙️') + expect(runningContent).toContain('Read') + expect(runningContent).toContain('🛠️') + + sc.completeTool('tu_1', 'Read') + await sleep(150) + steps = sc._getToolSteps() + expect(steps[0]!.status).toBe('done') + + // 最新 flush 应显示 "✅ Read" 不再有 "⚙️" + const lastContent = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + expect(lastContent).toContain('✅') + expect(lastContent).toContain('Read') + // 这一行整体换成了 `✅ Read`,不该再出现 ⚙️ 图标 + expect(lastContent).not.toContain('⚙️') + }) + + it('按 toolUseId 去重: 同一 id 不重复压入', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.startTool('tu_1', 'Read') + sc.startTool('tu_1', 'Read') + sc.startTool('tu_1', 'Read') + expect(sc._getToolSteps().length).toBe(1) + }) + + it('缺省 toolUseId 时按 name + index 合成 id,不同步骤可并存', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.startTool(undefined, 'Read') + sc.startTool(undefined, 'Read') + // 合成 id 不同 → 两个独立步骤 + expect(sc._getToolSteps().length).toBe(2) + }) + + it('completeTool 只匹配最近的 running 同名步骤', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.startTool('tu_1', 'Bash') + sc.startTool('tu_2', 'Bash') + sc.completeTool(undefined, 'Bash') + const steps = sc._getToolSteps() + // 更晚的 tu_2 被标记 done + expect(steps[0]!.status).toBe('running') + expect(steps[1]!.status).toBe('done') + }) + + it('空 toolName 忽略', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.startTool('tu_1', undefined) + sc.startTool('tu_1', '') + expect(sc._getToolSteps().length).toBe(0) + }) +}) + +// 复刻用户的真实场景: 用户发消息 → 服务端 thinking → tool_use → 最终 text。 +// 验证每个阶段都向 cardElement.content 写入了对应内容(不被 throttle / phase +// gate / 等任何东西吃掉)。 +describe('StreamingCard: 真实事件流(用户场景回归)', () => { + it('thinking → tool_use → text 应该在每个阶段都触发可见的 flush', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_real' } }, + 'im.message.create': { data: { message_id: 'om_real' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'oc_real' }) + + // 1. 用户发消息 → handleMessage 预建卡(fire-and-forget) + const creating = sc.ensureCreated() + await creating // 等卡可写 + + // 2. 服务端: status streaming + content_start{text} (thinking block) + // feishu/index.ts 的 content_start text 分支会再 await ensureCreated(no-op) + // (no direct call here — 等同于 no-op) + + // 3. 服务端: thinking deltas(5 个增量,间隔 30ms 模拟流式) + sc.appendReasoning('Analyzing the latest commits to find ') + await sleep(30) + sc.appendReasoning('breaking changes. Need to look at ') + await sleep(30) + sc.appendReasoning('the public API surface, the schema files, ') + await sleep(30) + sc.appendReasoning('and any removed exports. Let me check the ') + await sleep(30) + sc.appendReasoning('git log first.') + + // 等节流窗口结束 + await sleep(200) + + const flushesAfterReasoning = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content').length + expect(flushesAfterReasoning).toBeGreaterThan(0) + + const lastReasoningContent = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + // 应该包含 reasoning 累积内容 + expect(lastReasoningContent).toContain('breaking changes') + expect(lastReasoningContent).toContain('git log first') + + // 4. 服务端: content_start{tool_use, name: 'Bash'} + sc.startTool('tu_bash_1', 'Bash') + await sleep(150) + + const lastWithTool = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + expect(lastWithTool).toContain('Bash') + expect(lastWithTool).toContain('⚙️') + expect(lastWithTool).toContain('🛠️') + + // 5. 服务端: tool_use_complete + sc.completeTool('tu_bash_1', 'Bash') + await sleep(150) + + const lastAfterToolDone = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + expect(lastAfterToolDone).toContain('Bash') + // ⚙️ 切到 ✅ —— 当前唯一一步已完成 + expect(lastAfterToolDone).toContain('✅') + expect(lastAfterToolDone).not.toContain('⚙️') + + // 6. 第二个 tool 序列 + sc.startTool('tu_read_1', 'Read') + await sleep(150) + sc.completeTool('tu_read_1', 'Read') + await sleep(150) + + // 7. 最终 text 输出 + sc.appendText('## 破坏性变更分析\n\n') + await sleep(120) + sc.appendText('1. **API 重命名**: foo → bar\n') + await sleep(120) + sc.appendText('2. **删除导出**: baz') + await sleep(200) + + const lastWithText = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + // 应该同时包含 reasoning, tools, answer + expect(lastWithText).toContain('git log first') // reasoning + expect(lastWithText).toContain('Bash') // tool + expect(lastWithText).toContain('Read') // tool + expect(lastWithText).toContain('破坏性变更分析') // answer (post optimize: H2→H5) + expect(lastWithText).toContain('API 重命名') + + // 8. message_complete → finalize + await sc.finalize() + expect(sc._getPhase()).toBe('completed') + + // 验证有 settings + update 收尾 + expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true) + expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true) + }) + + it('cardKit 流式中第一帧失败不应永久禁用流式 —— 后续帧应能继续', async () => { + let firstFrameRejected = false + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_recover' } }, + 'im.message.create': { data: { message_id: 'om' } }, + 'cardElement.content': () => { + if (!firstFrameRejected) { + firstFrameRejected = true + // 模拟一个 *非* rate-limit、*非* table-limit 错误 + // 当前实现会把 cardKitStreamActive 设 false,本测试就是要发现这个问题 + const err: any = new Error('mystery cardkit error') + err.code = 999999 + throw err + } + return { code: 0 } + }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.appendReasoning('first thought') + await sleep(150) + // 此时第一帧已被拒,但我们期望流式仍然开着 —— 这样第二帧能继续 + sc.appendReasoning(' second thought') + await sleep(150) + // 验证: 至少尝试了 2 次 cardElement.content 调用 + const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + expect(contentCalls.length).toBeGreaterThanOrEqual(2) + // 而且 streaming 仍是 active + expect(sc._isCardKitStreamActive()).toBe(true) + }) +}) + +describe('StreamingCard: 组合渲染 (tools + reasoning + text)', () => { + it('三个 section 按顺序 tools → reasoning → answer 组合', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_all' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.appendReasoning('Should I read file A first?') + sc.startTool('tu_1', 'Read') + sc.appendText('Here is the answer.') + await sleep(150) + + const lastContent = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .pop()!.args.data.content as string + + const idxTools = lastContent.indexOf('🛠️') + const idxReasoning = lastContent.indexOf('思考中') + const idxAnswer = lastContent.indexOf('Here is the answer') + + expect(idxTools).toBeGreaterThan(-1) + expect(idxReasoning).toBeGreaterThan(-1) + expect(idxAnswer).toBeGreaterThan(-1) + // tools 在最顶部 → reasoning 居中 → answer 在底部 + expect(idxTools).toBeLessThan(idxReasoning) + expect(idxReasoning).toBeLessThan(idxAnswer) + }) + + it('ensureCreated 期间到达的 tool_use 在卡可写后立即 flush', async () => { + let resolveCreate: (() => void) | null = null + const createLatch = new Promise((r) => { resolveCreate = r }) + + const { client, calls } = makeMockClient({ + 'card.create': async () => { + await createLatch + return { code: 0, data: { card_id: 'ck_slow' } } + }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + + // 不 await: 在 create 还没 resolve 之前,先压入一个 tool step + const creating = sc.ensureCreated() + // 让事件循环推进到 create 被 await + await sleep(10) + sc.startTool('tu_1', 'Glob') + + // 此时 cardMessageReady 仍是 false —— 没有任何 flush + const contentBefore = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + expect(contentBefore.length).toBe(0) + + // 解锁 create → ensureCreated 继续 → setCardMessageReady(true) → 触发 pending flush + resolveCreate!() + await creating + await sleep(150) + + const contentAfter = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + expect(contentAfter.length).toBeGreaterThan(0) + const last = contentAfter[contentAfter.length - 1]! + expect(last.args.data.content).toContain('Glob') + expect(last.args.data.content).toContain('🛠️') + }) +}) diff --git a/adapters/feishu/card-errors.ts b/adapters/feishu/card-errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..89130a2c94d97100cd6dbc99247b77d46cb220c7 --- /dev/null +++ b/adapters/feishu/card-errors.ts @@ -0,0 +1,151 @@ +/** + * Feishu CardKit API 错误码解析与谓词 + * + * 参考实现: openclaw-lark/src/card/card-error.ts + src/core/api-error.ts + * + * Lark SDK 抛出的错误对象结构有多种: + * - SDK 把 Feishu 的 {code, msg} 直接挂在 error 对象上 + * - Axios 风格: error.response.data.{code, msg} + * - data.code 嵌套(某些包装层) + * + * 此模块把这些统一成 { code, subCode, errMsg } 结构, + * 供 streaming-card-controller 判断是否跳帧重试、或降级到 Patch 路径。 + */ + +// --------------------------------------------------------------------------- +// Error code constants +// --------------------------------------------------------------------------- + +/** 卡片 API 级别错误码。 */ +export const CARD_ERROR = { + /** 发送频率限制。需跳过当前帧,下次 flush 继续。 */ + RATE_LIMITED: 230020, + /** 卡片内容创建失败(通用码,需看子错误确认具体原因)。 */ + CARD_CONTENT_FAILED: 230099, +} as const + +/** + * 230099 的子错误码,嵌套在 msg 的 `ErrCode: xxx` 字段中。 + * 11310 是通用的"元素超限"码,需配合 errMsg 匹配具体原因。 + */ +export const CARD_CONTENT_SUB_ERROR = { + /** 卡片元素(表格等)数量超限 */ + ELEMENT_LIMIT: 11310, +} as const + +// --------------------------------------------------------------------------- +// Code extraction +// --------------------------------------------------------------------------- + +function coerceCode(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return undefined +} + +/** + * 从 Lark SDK 抛错对象中提取飞书 API code。支持三种结构: + * - `{ code }` (SDK 直接挂载) + * - `{ data: { code } }` (响应体嵌套) + * - `{ response: { data: { code } } }` (Axios 风格) + */ +export function extractLarkApiCode(err: unknown): number | undefined { + if (!err || typeof err !== 'object') return undefined + const e = err as { + code?: unknown + data?: { code?: unknown } + response?: { data?: { code?: unknown } } + } + return coerceCode(e.code) ?? coerceCode(e.data?.code) ?? coerceCode(e.response?.data?.code) +} + +// --------------------------------------------------------------------------- +// Sub-error extraction +// --------------------------------------------------------------------------- + +/** + * 从 msg 字符串里提取子错误码(`ErrCode: xxx`)。 + * + * 示例输入: + * "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ..." + * 返回: 11310 + */ +export function extractSubCode(msg: string): number | null { + const match = /ErrCode:\s*(\d+)/.exec(msg) + if (!match) return null + const code = Number(match[1]) + return Number.isFinite(code) ? code : null +} + +// --------------------------------------------------------------------------- +// Structured error parsing +// --------------------------------------------------------------------------- + +export type CardApiErrorInfo = { + code: number + subCode: number | null + errMsg: string +} + +/** + * 从任意抛错对象中解析卡片 API 错误结构。 + * + * 返回 { code, subCode, errMsg }。无法提取 code 时返回 null。 + */ +export function parseCardApiError(err: unknown): CardApiErrorInfo | null { + const code = extractLarkApiCode(err) + if (code === undefined) return null + + // 按优先级提取 msg 文本 + let errMsg = '' + if (err && typeof err === 'object') { + const e = err as { + msg?: unknown + message?: unknown + response?: { data?: { msg?: unknown } } + } + if (typeof e.msg === 'string') { + errMsg = e.msg + } else if (typeof e.response?.data?.msg === 'string') { + errMsg = e.response.data.msg + } else if (typeof e.message === 'string') { + errMsg = e.message + } + } + + const subCode = extractSubCode(errMsg) + return { code, subCode, errMsg } +} + +// --------------------------------------------------------------------------- +// Helper predicates +// --------------------------------------------------------------------------- + +/** 判断错误是否为卡片发送频率限制(230020)。 */ +export function isCardRateLimitError(err: unknown): boolean { + const parsed = parseCardApiError(err) + if (!parsed) return false + return parsed.code === CARD_ERROR.RATE_LIMITED +} + +/** + * 判断错误是否为卡片表格数超限。 + * + * 匹配条件: code 230099 + subCode 11310 + errMsg 含 "table number over limit" + * (11310 是通用元素超限码,光靠它不够;必须同时检查 errMsg 锁定是表格数量问题)。 + * + * 实际生产错误格式(openclaw-lark 2026-03 实测): + * "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; " + */ +export function isCardTableLimitError(err: unknown): boolean { + const parsed = parseCardApiError(err) + if (!parsed) return false + return ( + parsed.code === CARD_ERROR.CARD_CONTENT_FAILED && + parsed.subCode === CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT && + /table number over limit/i.test(parsed.errMsg) + ) +} diff --git a/adapters/feishu/cardkit.ts b/adapters/feishu/cardkit.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a343331da7dccfefc807762cb0c4b16252320ea --- /dev/null +++ b/adapters/feishu/cardkit.ts @@ -0,0 +1,337 @@ +/** + * 飞书 CardKit API 薄封装 + * + * 这是生产路径的核心:openclaw-lark 的 CardKit 主路径等价实现。 + * + * 五步流程: + * 1. createCardEntity() —— 创建卡片实体,返回 card_id + * 2. sendCardAsMessage() —— 通过 IM 消息把卡片挂到聊天窗,返回 message_id + * 3. streamCardContent() —— 循环调用,按 element_id 增量追加文本 + * 4. setCardStreamingMode() —— 关闭流式模式(收尾前必须做) + * 5. updateCardKitCard() —— 全量替换卡片为最终态 + * + * 关键约束: + * - 每次 3/4/5 类调用必须携带**单调递增**的 sequence,否则飞书拒绝 + * - streamCardContent 传的是**完整累计文本**,不是 delta + * - 必须关闭 streaming_mode 后卡片才能被用户交互 + * + * 参考实现: openclaw-lark/src/card/cardkit.ts + */ + +import type * as Lark from '@larksuiteoapi/node-sdk' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** 流式 markdown 元素的固定 element_id。卡片 JSON 里用这个 id 标记要被 + * `cardElement.content()` 更新的那一个 markdown 元素。 */ +export const STREAMING_ELEMENT_ID = 'streaming_content' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * SDK 返回的通用响应结构。 + * SDK 的 TypeScript 类型不完整,运行时实际返回 { code, msg, data }。 + * 我们统一当成 CardKitResponse 处理以免到处 `as any`。 + */ +type CardKitResponse = { + code?: number + msg?: string + data?: Record + [key: string]: unknown +} + +/** 非零 code 时抛出的结构化错误。字段与 Lark SDK 的标准错误对齐, + * 可被 card-errors.ts 的 parseCardApiError 识别。 */ +export class CardKitApiError extends Error { + readonly code: number + readonly msg: string + + constructor(params: { api: string; code: number; msg: string; context: string }) { + const { api, code, msg, context } = params + super(`cardkit ${api} FAILED: code=${code}, msg=${msg}, ${context}`) + this.name = 'CardKitApiError' + this.code = code + this.msg = msg + } +} + +type LarkClient = Lark.Client + +const DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS = 15_000 + +function getImCardRequestTimeoutMs(): number { + const raw = process.env.CC_HAHA_IM_CARD_REQUEST_TIMEOUT_MS + const parsed = raw ? Number(raw) : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_IM_CARD_REQUEST_TIMEOUT_MS +} + +export async function withImCardRequestTimeout( + api: string, + request: () => Promise, +): Promise { + const timeoutMs = getImCardRequestTimeoutMs() + let timer: ReturnType | undefined + + try { + return await Promise.race([ + Promise.resolve().then(request), + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`${api} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +// --------------------------------------------------------------------------- +// Response check +// --------------------------------------------------------------------------- + +/** + * 检查 CardKit 响应的 body-level code。非 0 → 抛 CardKitApiError。 + * + * Fail-fast 策略: 让 streaming-card 用 try/catch 配合 card-errors 统一 + * 判断是速率限制还是真错误。 + */ +function assertCardKitOk(params: { + resp: CardKitResponse + api: string + context: string +}): void { + const { resp, api, context } = params + const code = resp.code + if (code !== undefined && code !== 0) { + throw new CardKitApiError({ + api, + code, + msg: typeof resp.msg === 'string' ? resp.msg : '', + context, + }) + } +} + +// --------------------------------------------------------------------------- +// Step 1 — createCardEntity +// --------------------------------------------------------------------------- + +/** + * 创建一张 CardKit 卡片实体,返回 card_id。 + * + * 此时卡片还没挂到任何聊天窗。需要再调 sendCardAsMessage 才能显示。 + * + * @param client Lark SDK client + * @param card Schema 2.0 格式的卡片 JSON + * @returns 飞书分配的 card_id(失败时抛错) + */ +export async function createCardEntity( + client: LarkClient, + card: Record, +): Promise { + // SDK 返回类型不完整,cast 到运行时实际结构 + const resp = (await withImCardRequestTimeout('card.create', () => + client.cardkit.v1.card.create({ + data: { + type: 'card_json', + data: JSON.stringify(card), + }, + }), + )) as unknown as CardKitResponse + + assertCardKitOk({ + resp, + api: 'card.create', + context: `cardLen=${JSON.stringify(card).length}`, + }) + + // 兼容不同 SDK 包装层:data.card_id 优先,回退顶层 card_id + const cardId = + (resp.data?.card_id as string | undefined) ?? + (resp.card_id as string | undefined) + + if (!cardId) { + throw new CardKitApiError({ + api: 'card.create', + code: resp.code ?? -1, + msg: 'response missing card_id', + context: `resp=${JSON.stringify(resp).slice(0, 200)}`, + }) + } + return cardId +} + +// --------------------------------------------------------------------------- +// Step 2 — sendCardAsMessage +// --------------------------------------------------------------------------- + +/** + * 把 CardKit 卡片通过 IM 消息挂到聊天窗。 + * + * content 格式: `{"type":"card","data":{"card_id":"xxx"}}` + * msg_type 固定为 `interactive`。 + * + * @param client Lark SDK client + * @param chatId 目标 chat_id + * @param cardId CardKit card_id(由 createCardEntity 产生) + * @param replyToMessageId 可选。如果提供,走 im.message.reply;否则 im.message.create + * @returns 飞书分配的 message_id + */ +export async function sendCardAsMessage( + client: LarkClient, + chatId: string, + cardId: string, + replyToMessageId?: string, +): Promise { + const content = JSON.stringify({ + type: 'card', + data: { card_id: cardId }, + }) + + if (replyToMessageId) { + const resp = await withImCardRequestTimeout('im.message.reply', () => + client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { content, msg_type: 'interactive' }, + }), + ) + const messageId = resp.data?.message_id + if (!messageId) { + throw new CardKitApiError({ + api: 'im.message.reply', + code: -1, + msg: 'response missing message_id', + context: `cardId=${cardId}`, + }) + } + return messageId + } + + const resp = await withImCardRequestTimeout('im.message.create', () => + client.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'interactive', + content, + }, + }), + ) + const messageId = resp.data?.message_id + if (!messageId) { + throw new CardKitApiError({ + api: 'im.message.create', + code: -1, + msg: 'response missing message_id', + context: `chatId=${chatId} cardId=${cardId}`, + }) + } + return messageId +} + +// --------------------------------------------------------------------------- +// Step 3 — streamCardContent +// --------------------------------------------------------------------------- + +/** + * 流式更新指定 element 的内容。飞书自动对比旧内容做 diff,在客户端 + * 渲染打字机效果。 + * + * **重要**: `content` 必须传**完整累计文本**,不是 delta。 + * sequence 必须**单调递增**,否则飞书拒绝。 + * + * @param client Lark SDK client + * @param cardId CardKit card_id + * @param elementId 要更新的元素 id(通常是 STREAMING_ELEMENT_ID) + * @param content 完整累计文本 + * @param sequence 单调递增序列号 + */ +export async function streamCardContent( + client: LarkClient, + cardId: string, + elementId: string, + content: string, + sequence: number, +): Promise { + const resp = (await withImCardRequestTimeout('cardElement.content', () => + client.cardkit.v1.cardElement.content({ + data: { content, sequence }, + path: { card_id: cardId, element_id: elementId }, + }), + )) as unknown as CardKitResponse + + assertCardKitOk({ + resp, + api: 'cardElement.content', + context: `seq=${sequence} len=${content.length}`, + }) +} + +// --------------------------------------------------------------------------- +// Step 4 — setCardStreamingMode +// --------------------------------------------------------------------------- + +/** + * 开/关卡片的流式模式。收尾前必须调用 `streamingMode: false`, + * 否则卡片会保持"只读"状态,用户点按钮没反应。 + */ +export async function setCardStreamingMode( + client: LarkClient, + cardId: string, + streamingMode: boolean, + sequence: number, +): Promise { + const resp = (await withImCardRequestTimeout('card.settings', () => + client.cardkit.v1.card.settings({ + data: { + settings: JSON.stringify({ streaming_mode: streamingMode }), + sequence, + }, + path: { card_id: cardId }, + }), + )) as unknown as CardKitResponse + + assertCardKitOk({ + resp, + api: 'card.settings', + context: `seq=${sequence} streaming_mode=${streamingMode}`, + }) +} + +// --------------------------------------------------------------------------- +// Step 5 — updateCardKitCard +// --------------------------------------------------------------------------- + +/** + * 全量替换卡片为新的 JSON。用于流式结束后把卡片切换成最终态 + * (加 header template、footer、完成样式等)。 + */ +export async function updateCardKitCard( + client: LarkClient, + cardId: string, + card: Record, + sequence: number, +): Promise { + const resp = (await withImCardRequestTimeout('card.update', () => + client.cardkit.v1.card.update({ + data: { + card: { type: 'card_json', data: JSON.stringify(card) }, + sequence, + }, + path: { card_id: cardId }, + }), + )) as unknown as CardKitResponse + + assertCardKitOk({ + resp, + api: 'card.update', + context: `seq=${sequence} cardId=${cardId}`, + }) +} diff --git a/adapters/feishu/extract-payload.ts b/adapters/feishu/extract-payload.ts new file mode 100644 index 0000000000000000000000000000000000000000..440359f1f980de9ac826a6f9110b2c8529107c2e --- /dev/null +++ b/adapters/feishu/extract-payload.ts @@ -0,0 +1,95 @@ +/** + * Feishu inbound message parser. + * + * Converts a raw Feishu `im.message.receive_v1` event payload (the JSON + * string inside `message.content` plus its `message_type`) into a + * structured `InboundPayload` containing: + * - plain text (for direct forwarding to Claude) + * - a list of `PendingDownload` refs describing any attachments we + * need to fetch via FeishuMediaService.downloadResource() + * + * Supports the five message_type values we care about: + * - text → text only + * - post → rich text (text nodes + img + file elements) + * - image → single image_key + * - file → single file_key + * - file_archive → single file_key (same shape as file) + * + * Any other shape returns an empty payload (text: '', downloads: []). + */ + +export type PendingDownload = + | { kind: 'image'; fileKey: string; fileName?: string } + | { kind: 'file'; fileKey: string; fileName?: string } + +export interface InboundPayload { + text: string + pendingDownloads: PendingDownload[] +} + +export function extractInboundPayload(content: string, msgType: string): InboundPayload { + let parsed: any + try { + parsed = JSON.parse(content) + } catch { + return { text: '', pendingDownloads: [] } + } + + if (msgType === 'text') { + return { + text: typeof parsed.text === 'string' ? parsed.text : '', + pendingDownloads: [], + } + } + + if (msgType === 'image') { + if (typeof parsed.image_key === 'string' && parsed.image_key) { + return { + text: '', + pendingDownloads: [{ kind: 'image', fileKey: parsed.image_key }], + } + } + return { text: '', pendingDownloads: [] } + } + + if (msgType === 'file' || msgType === 'file_archive') { + if (typeof parsed.file_key === 'string' && parsed.file_key) { + return { + text: '', + pendingDownloads: [ + { + kind: 'file', + fileKey: parsed.file_key, + fileName: typeof parsed.file_name === 'string' ? parsed.file_name : undefined, + }, + ], + } + } + return { text: '', pendingDownloads: [] } + } + + if (msgType === 'post') { + const nodes = (parsed.zh_cn?.content ?? parsed.en_us?.content ?? []) as any[] + const flat = nodes.flat() + const textParts: string[] = [] + const downloads: PendingDownload[] = [] + for (const node of flat) { + if (!node || typeof node !== 'object') continue + if (node.tag === 'text' || node.tag === 'md') { + const t = node.text ?? node.content ?? '' + if (typeof t === 'string') textParts.push(t) + } else if (node.tag === 'img' && typeof node.image_key === 'string') { + downloads.push({ kind: 'image', fileKey: node.image_key }) + } else if (node.tag === 'file' && typeof node.file_key === 'string') { + downloads.push({ + kind: 'file', + fileKey: node.file_key, + fileName: typeof node.file_name === 'string' ? node.file_name : undefined, + }) + } + } + return { text: textParts.join(''), pendingDownloads: downloads } + } + + return { text: '', pendingDownloads: [] } +} diff --git a/adapters/feishu/flush-controller.ts b/adapters/feishu/flush-controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f16dd5e9c6fa8c4bd1cdf5713b288c43a07d758 --- /dev/null +++ b/adapters/feishu/flush-controller.ts @@ -0,0 +1,149 @@ +/** + * 节流 + mutex + 冲突重刷的通用 flush 调度器 + * + * 这是个纯调度原语 —— 不含任何业务逻辑(发送卡片、构造 markdown 等)。 + * 实际 flush 工作由构造函数注入的 doFlush 回调负责。 + * + * 语义: + * - throttledUpdate(throttleMs) 被流式数据触发,按窗口节流 + * - flush() 被 mutex 保护,相同时刻只有一个在跑 + * - flush 进行中的新数据标记 needsReflush,API 结束后立即补刷 + * - 长间隔(> 2000ms)后的第一次 flush 延迟 300ms 批量,避免抖动 + * - complete() 后拒绝所有新 flush + * + * 参考实现: openclaw-lark/src/card/flush-controller.ts + */ + +// --------------------------------------------------------------------------- +// Throttle constants +// --------------------------------------------------------------------------- + +export const THROTTLE = { + /** CardKit cardElement.content() 最小间隔 —— 官方为流式设计,可高频 */ + CARDKIT_MS: 100, + /** im.message.patch 最小间隔 —— 严格速率限制(230020) */ + PATCH_MS: 1500, + /** 长间隔判定阈值。elapsed > 2000ms 触发批量模式 */ + LONG_GAP_THRESHOLD_MS: 2000, + /** 长间隔后第一帧的额外延迟,让文本积累更完整 */ + BATCH_AFTER_GAP_MS: 300, +} as const + +// --------------------------------------------------------------------------- +// FlushController +// --------------------------------------------------------------------------- + +export class FlushController { + private flushInProgress = false + private flushResolvers: Array<() => void> = [] + private needsReflush = false + private pendingFlushTimer: ReturnType | null = null + private lastUpdateTime = 0 + private isCompleted = false + private _cardMessageReady = false + + constructor(private readonly doFlush: () => Promise) {} + + /** 标记完成 —— 当前 flush 跑完后不再接受新的。 */ + complete(): void { + this.isCompleted = true + } + + /** 取消任何挂起的延迟 flush 计时器。 */ + cancelPendingFlush(): void { + if (this.pendingFlushTimer) { + clearTimeout(this.pendingFlushTimer) + this.pendingFlushTimer = null + } + } + + /** 等待当前正在跑的 flush 结束。没在跑则立即返回。 */ + waitForFlush(): Promise { + if (!this.flushInProgress) return Promise.resolve() + return new Promise((resolve) => this.flushResolvers.push(resolve)) + } + + /** + * 标记卡片消息是否已发送成功,决定 flush 是否被放行。 + * + * 首次变 true 时同步更新 lastUpdateTime,让第一次 throttledUpdate + * 看到一个小的 elapsed,匹配 openclaw 的 "card 创建完立即可刷" 行为。 + */ + setCardMessageReady(ready: boolean): void { + this._cardMessageReady = ready + if (ready) this.lastUpdateTime = Date.now() + } + + cardMessageReady(): boolean { + return this._cardMessageReady + } + + /** + * 执行一次 flush(mutex 保护 + 冲突重刷)。 + * + * 如果已有 flush 在跑,设置 needsReflush,当前 flush 结束后自动补一次。 + */ + async flush(): Promise { + if (!this.cardMessageReady() || this.flushInProgress || this.isCompleted) { + if (this.flushInProgress && !this.isCompleted) this.needsReflush = true + return + } + this.flushInProgress = true + this.needsReflush = false + // 在 API 调用 **之前** 更新时间戳,防止并发调用者也进入 flush + this.lastUpdateTime = Date.now() + try { + await this.doFlush() + this.lastUpdateTime = Date.now() + } finally { + this.flushInProgress = false + const resolvers = this.flushResolvers + this.flushResolvers = [] + for (const resolve of resolvers) resolve() + + // 如果 API 调用期间有新事件进来,立即补一次 flush + if (this.needsReflush && !this.isCompleted && !this.pendingFlushTimer) { + this.needsReflush = false + this.pendingFlushTimer = setTimeout(() => { + this.pendingFlushTimer = null + void this.flush() + }, 0) + } + } + } + + /** + * 节流更新入口。 + * + * @param throttleMs - 最小 flush 间隔。CardKit 传 THROTTLE.CARDKIT_MS, + * Patch 降级路径传 THROTTLE.PATCH_MS。 + */ + async throttledUpdate(throttleMs: number): Promise { + if (!this.cardMessageReady()) return + + const now = Date.now() + const elapsed = now - this.lastUpdateTime + + if (elapsed >= throttleMs) { + this.cancelPendingFlush() + if (elapsed > THROTTLE.LONG_GAP_THRESHOLD_MS) { + // 长间隔批量模式:工具调用 / 推理回来后的第一帧延迟 300ms + // 让文本积累更完整,避免只显示一两个字 + this.lastUpdateTime = now + this.pendingFlushTimer = setTimeout(() => { + this.pendingFlushTimer = null + void this.flush() + }, THROTTLE.BATCH_AFTER_GAP_MS) + } else { + await this.flush() + } + } else if (!this.pendingFlushTimer) { + // 在节流窗口内 —— 延迟到窗口结束再刷 + const delay = throttleMs - elapsed + this.pendingFlushTimer = setTimeout(() => { + this.pendingFlushTimer = null + void this.flush() + }, delay) + } + } +} diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bea9e226a346dbc595adae53a4452e2cdb8e48da --- /dev/null +++ b/adapters/feishu/index.ts @@ -0,0 +1,1326 @@ +/** + * 飞书 (Feishu/Lark) Adapter for Claude Code Desktop + * + * 基于 @larksuiteoapi/node-sdk 的轻量飞书 Bot,直连服务端 /ws/:sessionId。 + * 使用 WebSocket 长连接接收事件,无需公网地址。 + * + * 启动:FEISHU_APP_ID=xxx FEISHU_APP_SECRET=xxx bun run feishu/index.ts + */ + +import * as Lark from '@larksuiteoapi/node-sdk' +import * as path from 'node:path' +import * as fs from 'node:fs/promises' +import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-bridge.js' +import { MessageDedup } from '../common/message-dedup.js' +import { StreamingCard } from './streaming-card.js' +import { enqueue } from '../common/chat-queue.js' +import { getConfiguredWorkDir, loadConfig } from '../common/config.js' +import { + formatImHelp, + formatImStatus, + formatPermissionRequest, + splitMessage, +} from '../common/format.js' +import { + formatPermissionDecisionStatus, + formatPermissionInstructions, + parsePermissionCommand, + type PermissionDecision, +} from '../common/permission.js' +import { SessionStore } from '../common/session-store.js' +import { AdapterHttpClient, type RecentProject } from '../common/http-client.js' +import { isAllowedUser, tryPair } from '../common/pairing.js' +import { optimizeMarkdownForFeishu } from './markdown-style.js' +import { extractInboundPayload } from './extract-payload.js' +import { FeishuMediaService } from './media.js' +import { AttachmentStore } from '../common/attachment/attachment-store.js' +import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js' +import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js' +import type { PendingUpload } from '../common/attachment/attachment-types.js' +import { isOutsideWorkDir } from './path-safety.js' + +// ---------- init ---------- + +const config = loadConfig() +if (!config.feishu.appId || !config.feishu.appSecret) { + console.error('[Feishu] Missing FEISHU_APP_ID / FEISHU_APP_SECRET. Set env or ~/.claude/adapters.json') + process.exit(1) +} + +const larkClient = new Lark.Client({ + appId: config.feishu.appId, + appSecret: config.feishu.appSecret, + appType: Lark.AppType.SelfBuild, + domain: Lark.Domain.Feishu, +}) + +const bridge = new WsBridge(config.serverUrl, 'feishu') +const dedup = new MessageDedup() +const sessionStore = new SessionStore() +const defaultWorkDir = getConfiguredWorkDir(config, config.feishu) +const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] }) + +// Attachment plumbing — shared by inbound (download) and outbound (upload) paths. +const attachmentStore = new AttachmentStore() +const media = new FeishuMediaService(larkClient, attachmentStore) +attachmentStore.gc().catch((err) => { + console.warn('[Feishu] AttachmentStore.gc failed:', err instanceof Error ? err.message : err) +}) + +// One streaming card lifecycle per chatId (CardKit main + patch fallback). +const streamingCards = new Map() +const pendingProjectSelection = new Map() +const runtimeStates = new Map() +const pendingPermissions = new Map>() + +// Per-chat outbound watchers for Agent-produced markdown image references. +// `imageWatchers` extracts `![alt](src)` from streaming text; +// `uploadedImageKeys` caches fingerprint → image_key so the same image +// referenced multiple times in one turn isn't re-uploaded. +const imageWatchers = new Map() +const uploadedImageKeys = new Map>() + +// Bot's own open_id (resolved on first message) +let botOpenId: string | null = null +// WSClient reference for graceful shutdown +let wsClient: InstanceType | null = null + +type ChatRuntimeState = { + state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending' + verb?: string + model?: string + pendingPermissionCount: number +} + +// ---------- helpers ---------- + +function getRuntimeState(chatId: string): ChatRuntimeState { + let state = runtimeStates.get(chatId) + if (!state) { + state = { state: 'idle', pendingPermissionCount: 0 } + runtimeStates.set(chatId, state) + } + return state +} + +/** Get the existing StreamingCard for this chat, or create one in 'idle' state. */ +function getOrCreateStreamingCard(chatId: string): StreamingCard { + let card = streamingCards.get(chatId) + if (!card) { + card = new StreamingCard({ larkClient, chatId }) + streamingCards.set(chatId, card) + } + return card +} + +function getImageWatcher(chatId: string): ImageBlockWatcher { + let w = imageWatchers.get(chatId) + if (!w) { + w = new ImageBlockWatcher() + imageWatchers.set(chatId, w) + } + return w +} + +function getUploadedKeys(chatId: string): Map { + let m = uploadedImageKeys.get(chatId) + if (!m) { + m = new Map() + uploadedImageKeys.set(chatId, m) + } + return m +} + +/** Upload a PendingUpload found in streaming output and send it as an + * independent im.message.create({msg_type:'image'}) message — runs + * fire-and-forget so the streaming card is never blocked. All failure + * modes are non-fatal: log and skip. */ +async function dispatchOutboundImage(chatId: string, pending: PendingUpload): Promise { + const cache = getUploadedKeys(chatId) + if (cache.has(pending.id)) return // already uploaded within this chat + + try { + let buffer: Buffer + let mime = 'image/png' + switch (pending.source.kind) { + case 'base64': { + buffer = Buffer.from(pending.source.data, 'base64') + mime = pending.source.mime + break + } + case 'path': { + buffer = await fs.readFile(pending.source.path) + mime = pending.source.mime ?? 'image/png' + break + } + case 'url': { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 30_000) + try { + const resp = await fetch(pending.source.url, { signal: controller.signal }) + if (!resp.ok) throw new Error(`fetch ${pending.source.url} -> ${resp.status}`) + buffer = Buffer.from(await resp.arrayBuffer()) + mime = pending.source.mime ?? resp.headers.get('content-type') ?? 'image/png' + } finally { + clearTimeout(timer) + } + break + } + } + + const check = checkAttachmentLimit('image', buffer.length, mime) + if (!check.ok) { + console.warn('[Feishu] Outbound image rejected:', check.hint) + return + } + + const imageKey = await media.uploadImage(buffer, mime) + cache.set(pending.id, imageKey) + await media.sendImageMessage(chatId, imageKey) + } catch (err) { + console.error( + '[Feishu] dispatchOutboundImage failed:', + err instanceof Error ? err.message : err, + ) + } +} + +/** Finalize and remove the streaming card (normal completion). */ +async function finalizeStreamingCard(chatId: string): Promise { + const card = streamingCards.get(chatId) + if (!card) return + streamingCards.delete(chatId) + await card.finalize() +} + +/** Abort and remove the streaming card (error path). Non-throwing. */ +async function abortStreamingCard(chatId: string, err: Error): Promise { + const card = streamingCards.get(chatId) + if (!card) return + streamingCards.delete(chatId) + await card.abort(err).catch(() => {}) +} + +function clearTransientChatState(chatId: string): void { + // Abort any in-flight streaming card (best effort, don't block) + const card = streamingCards.get(chatId) + if (card) { + streamingCards.delete(chatId) + void card.abort(new Error('session cleared')).catch(() => {}) + } + imageWatchers.delete(chatId) + uploadedImageKeys.delete(chatId) + const runtime = getRuntimeState(chatId) + runtime.state = 'idle' + runtime.verb = undefined + runtime.pendingPermissionCount = 0 + pendingPermissions.delete(chatId) +} + +async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { + const stored = sessionStore.get(chatId) + if (!stored) return null + + if (!bridge.hasSession(chatId)) { + bridge.connectSession(chatId, stored.sessionId) + bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) + const opened = await bridge.waitForOpen(chatId) + if (!opened) return null + } + + return stored +} + +async function buildStatusText(chatId: string): Promise { + const stored = await ensureExistingSession(chatId) + if (!stored) return formatImStatus(null) + + const runtime = getRuntimeState(chatId) + let projectName = path.basename(stored.workDir) || stored.workDir + let branch: string | null = null + + try { + const gitInfo = await httpClient.getGitInfo(stored.sessionId) + projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName + branch = gitInfo.branch + } catch { + // Ignore git lookup failures and fall back to stored workDir + } + + let taskCounts: + | { + total: number + pending: number + inProgress: number + completed: number + } + | undefined + + try { + const tasks = await httpClient.getTasksForSession(stored.sessionId) + if (tasks.length > 0) { + taskCounts = { + total: tasks.length, + pending: tasks.filter((task) => task.status === 'pending').length, + inProgress: tasks.filter((task) => task.status === 'in_progress').length, + completed: tasks.filter((task) => task.status === 'completed').length, + } + } + } catch { + // Ignore task lookup failures in IM status summary + } + + return formatImStatus({ + sessionId: stored.sessionId, + projectName, + branch, + model: runtime.model, + state: runtime.state, + verb: runtime.verb, + pendingPermissionCount: runtime.pendingPermissionCount, + taskCounts, + }) +} + +/** Send a text message (post format). */ +async function sendText(chatId: string, text: string, replyToMessageId?: string): Promise { + const content = JSON.stringify({ + zh_cn: { content: [[{ tag: 'md', text }]] }, + }) + + try { + if (replyToMessageId) { + const resp = await larkClient.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { content, msg_type: 'post' }, + }) + return resp.data?.message_id + } + const resp = await larkClient.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'post' as const, + content, + }, + }) + return resp.data?.message_id + } catch (err) { + console.error('[Feishu] Send text error:', err) + return undefined + } +} + +/** Send an interactive card (for permission requests). */ +async function sendCard(chatId: string, card: Record): Promise { + try { + const resp = await larkClient.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'interactive', + content: JSON.stringify(card), + }, + }) + return resp.data?.message_id + } catch (err) { + console.error('[Feishu] Send card error:', err) + return undefined + } +} + +/** Pretty-print an absolute path for IM display. + * - Replace $HOME with `~` + * - Middle-truncate if it's still very long, keeping the project tail visible */ +function prettyPath(realPath: string, maxLen = 64): string { + const home = process.env.HOME + let p = realPath + if (home) { + if (p === home) return '~' + if (p.startsWith(`${home}/`)) p = `~${p.slice(home.length)}` + } + if (p.length <= maxLen) return p + // Project name lives at the tail — keep more of the tail than the head. + const tailLen = Math.floor(maxLen * 0.65) + const headLen = maxLen - tailLen - 1 + return `${p.slice(0, headLen)}…${p.slice(-tailLen)}` +} + +/** Build an interactive project picker card — mobile-first layout. + * + * Design: one column_set per project with exactly 2 columns: + * - Col 1 (weighted): project info (title markdown + small grey path) + * - Col 2 (auto): "选择" button, vertically centered + * + * Only 2 columns with one weighted + one auto means the weight distribution + * is trivial (auto takes its natural width, weighted takes the rest). This + * avoids the layout issues seen in 3-column attempts. */ +function buildProjectPickerCard(projects: RecentProject[]): Record { + const items = projects.slice(0, 10) + const total = projects.length + const subtitleText = + total > items.length + ? `共 ${total} 个最近项目,显示前 ${items.length}` + : `共 ${total} 个最近项目` + + const rows = items.map((p, i) => { + const branch = p.branch ? ` · *${p.branch}*` : '' + return { + tag: 'column_set', + flex_mode: 'stretch', + horizontal_spacing: '8px', + margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0', + columns: [ + // Col 1 — project info (title + notation path, stacked) + { + tag: 'column', + width: 'weighted', + weight: 1, + vertical_align: 'center', + elements: [ + { + tag: 'markdown', + content: `**${p.projectName}**${branch}`, + }, + { + tag: 'markdown', + content: prettyPath(p.realPath, 56), + text_size: 'notation', + margin: '2px 0 0 0', + }, + ], + }, + // Col 2 — action button (auto width, vertically centered) + { + tag: 'column', + width: 'auto', + vertical_align: 'center', + elements: [ + { + tag: 'button', + text: { tag: 'plain_text', content: '选择' }, + type: i === 0 ? 'primary' : 'default', + size: 'small', + value: { + action: 'pick_project', + realPath: p.realPath, + projectName: p.projectName, + }, + }, + ], + }, + ], + } + }) + + return { + schema: '2.0', + config: { + wide_screen_mode: true, + update_multi: true, + }, + header: { + title: { tag: 'plain_text', content: '📁 选择项目' }, + subtitle: { tag: 'plain_text', content: subtitleText }, + template: 'blue', + }, + body: { + elements: [ + ...rows, + { tag: 'hr', margin: '14px 0 0 0' }, + { + tag: 'markdown', + content: '💡 点击右侧 **选择** 按钮,或发送 `/new <项目名>`', + text_size: 'notation', + margin: '6px 0 0 0', + }, + ], + }, + } +} + +/** Human-readable summary of a tool call for display in the permission card. */ +type ToolCallSummary = { + icon: string + label: string + /** Display string for the operation target (file path or command preview) */ + target?: string + /** Absolute file path for cross-directory detection, when applicable */ + filePath?: string +} + +/** Map a Claude Code tool call to an icon + human-readable Chinese label. + * Unknown tools fall back to the raw tool name with a generic icon. */ +function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary { + const rec: Record = + input && typeof input === 'object' ? (input as Record) : {} + const str = (key: string): string | undefined => + typeof rec[key] === 'string' ? (rec[key] as string) : undefined + + switch (toolName) { + case 'Write': { + const fp = str('file_path') + return { icon: '✏️', label: '写入文件', target: fp, filePath: fp } + } + case 'Edit': + case 'MultiEdit': + case 'NotebookEdit': { + const fp = str('file_path') ?? str('notebook_path') + return { icon: '✏️', label: '修改文件', target: fp, filePath: fp } + } + case 'Read': { + const fp = str('file_path') + return { icon: '📖', label: '读取文件', target: fp, filePath: fp } + } + case 'Bash': + case 'BashOutput': { + return { icon: '🖥️', label: '执行命令', target: str('command') } + } + case 'Grep': { + const pattern = str('pattern') + return { + icon: '🔍', + label: '搜索内容', + target: pattern ? `pattern: ${pattern}` : undefined, + filePath: str('path'), + } + } + case 'Glob': { + const pattern = str('pattern') + return { + icon: '📁', + label: '查找文件', + target: pattern ? `pattern: ${pattern}` : undefined, + filePath: str('path'), + } + } + case 'WebFetch': + return { icon: '🌐', label: '访问网页', target: str('url') } + case 'WebSearch': + return { icon: '🌐', label: '搜索网页', target: str('query') } + default: + return { icon: '🔧', label: toolName } + } +} + +/** Truncate a single-line target preview (e.g. shell command) to maxLen. */ +function truncateTarget(s: string, maxLen = 160): string { + if (s.length <= maxLen) return s + return s.slice(0, maxLen - 1) + '…' +} + +/** Build a permission request card (Schema 2.0, mobile-friendly). + * + * Layout: + * header → 🔐 需要权限确认 (orange / red if cross-dir) + * body → **