diff --git a/adapters/README.md b/adapters/README.md deleted file mode 100644 index 4f34711adb2853a7b59b08c5891e6142ab4bb16b..0000000000000000000000000000000000000000 --- a/adapters/README.md +++ /dev/null @@ -1,101 +0,0 @@ -# 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 deleted file mode 100644 index afad970551890301fc5605a3fc3afd82a557000a..0000000000000000000000000000000000000000 --- a/adapters/bun.lock +++ /dev/null @@ -1,150 +0,0 @@ -{ - "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 deleted file mode 100644 index 36c8c9c246d98b656581028425f331793f552d2b..0000000000000000000000000000000000000000 --- a/adapters/common/__tests__/chat-queue.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 6fa34847306b734232ec0de923c1ae74a938ba5a..0000000000000000000000000000000000000000 --- a/adapters/common/__tests__/config.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index 0597516f9b563f995cc19aacf5a8d983785383f8..0000000000000000000000000000000000000000 --- a/adapters/common/__tests__/format.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -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 deleted file mode 100644 index e4c83622f127cf5fc0129cd33ff01f4179ad7005..0000000000000000000000000000000000000000 --- a/adapters/common/__tests__/http-client.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -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 deleted file mode 100644 index 9e8a037b2009eb7732f016b9a446993fe658fbc5..0000000000000000000000000000000000000000 --- a/adapters/common/__tests__/message-buffer.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index 68f41fb47b48320cfb1575e5a33fee83b36966c2..0000000000000000000000000000000000000000 --- a/adapters/common/__tests__/message-dedup.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 7f45547885eaa53b3aea5794b71b515982225e95..0000000000000000000000000000000000000000 --- a/adapters/common/__tests__/pairing.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 6fa01885a1cc1dbf60ee95de030d7f8973747cfe..0000000000000000000000000000000000000000 --- a/adapters/common/__tests__/permission.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index a029084ee2971970cc6020cb2c6707362f133b86..0000000000000000000000000000000000000000 --- a/adapters/common/__tests__/session-store.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 7e8618b21a25f1a778b2410642ef858761c829fc..0000000000000000000000000000000000000000 --- a/adapters/common/__tests__/ws-bridge.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -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 deleted file mode 100644 index 32cea65398d3690853d3a690602704f8a197cd37..0000000000000000000000000000000000000000 --- a/adapters/common/attachment/__tests__/attachment-limits.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 9b54c6b6506da6098ea813c20002c84c6bcb9038..0000000000000000000000000000000000000000 --- a/adapters/common/attachment/__tests__/attachment-store.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index 2fac9ef9a28c3ce0e4ee0e233322a41ae38c346f..0000000000000000000000000000000000000000 --- a/adapters/common/attachment/__tests__/image-block-watcher.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index c3e87287e9bc807d4baf63b13b446fdbcd925e1d..0000000000000000000000000000000000000000 --- a/adapters/common/attachment/attachment-limits.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 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 deleted file mode 100644 index 0214f9f380a2f858d020ede7919e97ae4a69ec74..0000000000000000000000000000000000000000 --- a/adapters/common/attachment/attachment-store.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * 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 deleted file mode 100644 index 9574695b9b095a9d43d7483d9cc6947daae054b3..0000000000000000000000000000000000000000 --- a/adapters/common/attachment/attachment-types.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * 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 deleted file mode 100644 index cce5c8263b15e221a6fe509dd4cb2e8081e7425a..0000000000000000000000000000000000000000 --- a/adapters/common/attachment/image-block-watcher.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * 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 deleted file mode 100644 index a0c0190edb6479b8a07bd043b68da811f90e9e7c..0000000000000000000000000000000000000000 --- a/adapters/common/chat-queue.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * 会话串行队列 - * - * 同一 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 deleted file mode 100644 index 095504d51339d166b86557fbc75bbc26c1faee2f..0000000000000000000000000000000000000000 --- a/adapters/common/config.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * 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 deleted file mode 100644 index d011d67a6f84d9e5e36bdc8e797dd02ca5561d63..0000000000000000000000000000000000000000 --- a/adapters/common/format.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * 消息格式化工具 - */ - -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 deleted file mode 100644 index 61bd0bcbc06d288bf9944c1bd68200cb87ced0e6..0000000000000000000000000000000000000000 --- a/adapters/common/http-client.ts +++ /dev/null @@ -1,207 +0,0 @@ -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 deleted file mode 100644 index b5a5006a62483cafeab9a754d9f6ecdccc79476b..0000000000000000000000000000000000000000 --- a/adapters/common/message-buffer.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * 流式消息缓冲 - * - * 将 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 deleted file mode 100644 index 7c8bf409450f994a3546aec90f89f03ae390b6a7..0000000000000000000000000000000000000000 --- a/adapters/common/message-dedup.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 消息去重 - * - * 防止 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 deleted file mode 100644 index 5616044eca4c490aa64406588b6445400bdd3961..0000000000000000000000000000000000000000 --- a/adapters/common/pairing.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * 配对核心逻辑 - * - * - 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 deleted file mode 100644 index 2937d79666e4ed8abd7cda24a5b1aae36c55b375..0000000000000000000000000000000000000000 --- a/adapters/common/permission.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 2ca4f16c46418bc2fdadc011555fed07dd708cd3..0000000000000000000000000000000000000000 --- a/adapters/common/session-store.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 2ccc6ca5862438488e1f9ee38fc84b9667f12153..0000000000000000000000000000000000000000 --- a/adapters/common/ws-bridge.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * 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 deleted file mode 100644 index 58a18248d18f516acba172b97860affc00936cb3..0000000000000000000000000000000000000000 --- a/adapters/dingtalk/__tests__/ai-card.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index b072da1bc00af1413eec001c342fc7812b096dad..0000000000000000000000000000000000000000 --- a/adapters/dingtalk/__tests__/helpers.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index e33162ea51efe33bc3dcda3952f7e38f8da49e77..0000000000000000000000000000000000000000 --- a/adapters/dingtalk/__tests__/permission-card.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 920096eee807dcbec927cbd1389864d2ff2b8ab1..0000000000000000000000000000000000000000 --- a/adapters/dingtalk/__tests__/stream-state.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 2a9d3fb36460af3a1818bf16b531ce36cef7e294..0000000000000000000000000000000000000000 --- a/adapters/dingtalk/ai-card.ts +++ /dev/null @@ -1,305 +0,0 @@ -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 deleted file mode 100644 index a88592ed32cb2037b0b8a2e8fe708c7d453c0bb3..0000000000000000000000000000000000000000 --- a/adapters/dingtalk/helpers.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index 4b2bce320307305d385fca9bcd13979f4102c52b..0000000000000000000000000000000000000000 --- a/adapters/dingtalk/index.ts +++ /dev/null @@ -1,713 +0,0 @@ -/** - * 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 deleted file mode 100644 index 33225326ef6b9b32e56ea0a40a6102d38193b29b..0000000000000000000000000000000000000000 --- a/adapters/dingtalk/media.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index fa6bf64cb6c9e4a326c12f21149f24e3ecfc528f..0000000000000000000000000000000000000000 --- a/adapters/dingtalk/permission-card.ts +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index 7ea4e527aaab53fd3c8fce08303a1bdf09981e6e..0000000000000000000000000000000000000000 --- a/adapters/dingtalk/stream-state.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index dc958d7ca7b6e705c7d6fb6e5d05bafb6ff157ee..0000000000000000000000000000000000000000 --- a/adapters/feishu/__tests__/card-errors.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * 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 deleted file mode 100644 index bbcc556137cb4a51009a6c25c06b707efca1fda7..0000000000000000000000000000000000000000 --- a/adapters/feishu/__tests__/cardkit.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * 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 deleted file mode 100644 index 494b58c151cee9069ec345fd4cc1d218e6b898d3..0000000000000000000000000000000000000000 --- a/adapters/feishu/__tests__/extract-payload.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 143776394e2205eec2b9c492b03a7a656575ef07..0000000000000000000000000000000000000000 --- a/adapters/feishu/__tests__/feishu.test.ts +++ /dev/null @@ -1,899 +0,0 @@ -/** - * 飞书 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 deleted file mode 100644 index 3324c0cff70c2221e609b26693988a2a8d7dbbce..0000000000000000000000000000000000000000 --- a/adapters/feishu/__tests__/flush-controller.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * 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 deleted file mode 100644 index 26fcf1d8428ee2084416e7687f632a31301bf34e..0000000000000000000000000000000000000000 --- a/adapters/feishu/__tests__/markdown-style.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * 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 deleted file mode 100644 index 5b350a99c8583c06fb60e77599c0062d5126c783..0000000000000000000000000000000000000000 --- a/adapters/feishu/__tests__/media.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index fbb090aec73e02f4cfbd8b559857f55366fee634..0000000000000000000000000000000000000000 --- a/adapters/feishu/__tests__/streaming-card.test.ts +++ /dev/null @@ -1,947 +0,0 @@ -/** - * 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 deleted file mode 100644 index 89130a2c94d97100cd6dbc99247b77d46cb220c7..0000000000000000000000000000000000000000 --- a/adapters/feishu/card-errors.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * 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 deleted file mode 100644 index 4a343331da7dccfefc807762cb0c4b16252320ea..0000000000000000000000000000000000000000 --- a/adapters/feishu/cardkit.ts +++ /dev/null @@ -1,337 +0,0 @@ -/** - * 飞书 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 deleted file mode 100644 index 440359f1f980de9ac826a6f9110b2c8529107c2e..0000000000000000000000000000000000000000 --- a/adapters/feishu/extract-payload.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * 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 deleted file mode 100644 index 4f16dd5e9c6fa8c4bd1cdf5713b288c43a07d758..0000000000000000000000000000000000000000 --- a/adapters/feishu/flush-controller.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * 节流 + 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 deleted file mode 100644 index bea9e226a346dbc595adae53a4452e2cdb8e48da..0000000000000000000000000000000000000000 --- a/adapters/feishu/index.ts +++ /dev/null @@ -1,1326 +0,0 @@ -/** - * 飞书 (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 → **