diff --git a/changelog.d/1266-feishu-inbound.added.md b/changelog.d/1266-feishu-inbound.added.md new file mode 100644 index 00000000..fb31abe3 --- /dev/null +++ b/changelog.d/1266-feishu-inbound.added.md @@ -0,0 +1 @@ +Feishu (Lark) connector, phase 2: a message the owner types in Feishu can start a turn here over the long connection. The app pair `FEISHU_APP_ID` / `FEISHU_APP_SECRET` is saved through `secret-set` and stays out of `state.json` and the view; the connection is off until `feishu.enabled` is true, dials only from an app pair that is complete, and only the owner's messages start a turn. A replayed `event_id` lands once — the id is remembered only after the message reached its chat, so a handover that failed is retried rather than dropped — the message arrives through the same chat seam a friend's Bot uses, and the answer leaves again on the same `FEISHU_WEBHOOK` the connector already posts with, so one chat has one way in and one way out. ([#1266](https://github.com/ThinkFlowLab/FlyBot/issues/1266)) diff --git a/core/package-lock.json b/core/package-lock.json index fd6cfd26..bd5626f4 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -8,6 +8,9 @@ "name": "ds-bot", "version": "0.2.9", "license": "Apache-2.0", + "dependencies": { + "@larksuiteoapi/node-sdk": "1.76.0" + }, "devDependencies": { "esbuild": "0.28.2" }, @@ -460,6 +463,243 @@ "node": ">=18" } }, + "node_modules/@larksuiteoapi/node-sdk": { + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.76.0.tgz", + "integrity": "sha512-v/bxQNCugwAmMPbZtBGJtSu8hdQLiB1/29Jr+GBIG18DyQ4LYyp7gxQ74pkhXVSuoUeH4v+/ZHAw/SW+KMrj2g==", + "license": "MIT", + "dependencies": { + "axios": "^1.16.0", + "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" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -501,6 +741,371 @@ "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" } + }, + "node_modules/follow-redirects": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", + "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash.identity": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz", + "integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/lodash.pickby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz", + "integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/core/package.json b/core/package.json index 2c4e23c8..414c81eb 100644 --- a/core/package.json +++ b/core/package.json @@ -59,6 +59,9 @@ "@deepseek-ai/dsh-tools": "*", "@deepseek-ai/dsh-web": "*" }, + "dependencies": { + "@larksuiteoapi/node-sdk": "1.76.0" + }, "dsh": { "client": { "platform": "web" diff --git a/core/src/host/connectors.js b/core/src/host/connectors.js index bdfc219c..3753869b 100644 --- a/core/src/host/connectors.js +++ b/core/src/host/connectors.js @@ -18,6 +18,8 @@ import { randomUUID } from 'node:crypto' import { createNotifier } from './notify.js' +import { appCredentials, createInbound } from './feishu-inbound.js' +import { createFeishuTransport } from './feishu-sdk.js' export const GITHUB = 'github' export const GITHUB_SECRET = 'GITHUB_TOKEN' @@ -316,6 +318,20 @@ export function feishuTransport(webhook, { fetch: request = fetch, timeoutMs = S return { postMessage: message => feishuPost(webhook, message?.text ?? '', { fetch: request, timeoutMs }) } } +// The way out for an answer to a Feishu message (#1266): the webhook phase 1 already posts on, so a +// message in and an answer out are one credential and one transport. Kept out of `install` so the +// exit can be asserted without a socket. A missing webhook or a refusal is a sentence the chat +// shows ("Not sent: …"), never a silent drop: `remote-chat` prints exactly what is thrown here. +export function feishuReply({ secretValue, post = feishuPost, timeoutMs = SEND_TIMEOUT_MS } = {}) { + return async ({ text }) => { + const webhook = secretValue?.(FEISHU_SECRET) + if (webhook === undefined) throw new Error('Not sent: connect Feishu first, so the answer has a webhook to leave on.') + const answer = await post(webhook, String(text ?? ''), { timeoutMs }) + if (answer?.ok === true) return + throw new Error(`Not sent: Feishu refused the answer (${answer?.error ?? 'no answer'})`) + } +} + const DEFINITIONS = { [GITHUB]: { id: GITHUB, @@ -414,6 +430,55 @@ export function install(rt) { } }) + // Feishu inbound (#1266): a message in Feishu starts a turn here. The socket is injected — + // `rt.feishuTransport` is the one adapter the SDK-versus-hand-written decision changes, so nothing + // below depends on which client dials. Off unless the owner turned it on, and with no socket adapter + // it starts nothing rather than pretend. The message lands through `remote-chat.deliver` — the same + // seam a friend's Bot uses — and the answer leaves through the sender this module registers in that + // same seam, on the webhook phase 1 posts on: one path in, one path out. + // + // The default adapter is the official SDK, and it is put on the runtime *before* the policy reads + // it. That read happens on every `start()` rather than once here, so an adapter installed later + // (a test's stub, a plugin's own client) is the one the next start dials instead of a snapshot + // taken while it was missing. + if (rt.feishuTransport === undefined) { + rt.feishuTransport = createFeishuTransport({ + credentials: () => appCredentials(name => rt.secretValue(name)), + log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), + }) + } + // One way out per Feishu chat: `remote-chat` keys a reply's sender by the chat's plugin, so this + // registers the webhook post under `feishu`. Registered against the seam object it saw, so a runtime + // that replaces the seam is wired again instead of keeping a dead registration. + let feishuWire = { seam: undefined, off: undefined } + const wireFeishuReply = () => { + const seam = rt.remoteChats + if (typeof seam?.onSend !== 'function') return + if (feishuWire.seam === seam) return + feishuWire.off?.() + feishuWire = { seam, off: seam.onSend(FEISHU, feishuReply({ secretValue: name => rt.secretValue(name) })) } + } + + const feishuInbound = createInbound({ + enabled: rt.config?.feishu?.enabled === true, + ownerOpenId: rt.config?.feishu?.ownerOpenId, + transport: () => rt.feishuTransport, + deliver: async ({ handle, text, name }) => { + const seam = rt.remoteChats + if (typeof seam?.deliver !== 'function') throw new Error('no chat seam is installed for a Feishu message') + // The seam is installed after this module, so the answer's sender is registered when the first + // message lands rather than while module install runs. + wireFeishuReply() + // `plugin` is what `remote-chat.forward` keys the sender by. Without it the chat is opened with + // `remote.plugin === undefined` and every answer reads "Not sent: the plugin that reaches this + // Bot is not running." `open` refreshes a chat that an older build opened without the plugin. + if (typeof seam.open === 'function') await seam.open({ handle, name, plugin: FEISHU }) + await seam.deliver({ handle, text, name, plugin: FEISHU }) + }, + log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), + redact: rt.redactSecrets, + }) + const saved = c => state.connectors?.[c.def.id] const unmount = async (c) => { const fiber = c.mount?.fiber @@ -537,6 +602,7 @@ export function install(rt) { ...(c.def.kind !== undefined ? { kind: c.def.kind } : {}), account: rt.secretValue(c.def.secret) !== undefined ? c.def.account(saved(c)) : null, ...(c.def.extra !== undefined ? c.def.extra(rt.secretValue(c.def.secret) !== undefined ? saved(c) : undefined) : {}), + ...(c.def.id === FEISHU ? { inbound: { ...feishuInbound.view(), app: appCredentials(name => rt.secretValue(name)) === undefined ? 'missing' : 'set' } } : {}), }) const connectorsView = () => connectors.filter(c => c.options !== null).map(view) @@ -670,6 +736,10 @@ export function install(rt) { const c = known(body.id) await rt.loadSecrets() await sync(c, true) + // "Try again" is one press for the whole row: a Feishu row proves the webhook and, when the + // long connection is switched on, dials it again — which is how an adapter installed after the + // first attempt comes up without a restart. + if (c.def.id === FEISHU) await feishuInbound.start() return connectorsView() }, // One message, on demand, once. Nothing posts room traffic on its own: that is a spam @@ -707,9 +777,17 @@ export function install(rt) { ctx.effect(() => { stopped = false - void scrubAddresses().catch(error => rt.warn('ds-bot: connectors: %s', reason(error))).then(() => rt.loadSecrets()).then(syncAll) + // The long connection dials once the secrets are loaded: the app pair lives there, so starting + // before that read would report a missing app that is in fact saved. + void scrubAddresses().catch(error => rt.warn('ds-bot: connectors: %s', reason(error))).then(() => rt.loadSecrets()).then(async () => { + await syncAll() + if (!stopped) await feishuInbound.start() + }) return () => { stopped = true + feishuWire.off?.() + feishuWire = { seam: undefined, off: undefined } + void feishuInbound.stop() for (const c of connectors) void unmount(c) } }, 'ds-bot: connectors') diff --git a/core/src/host/feishu-inbound.js b/core/src/host/feishu-inbound.js new file mode 100644 index 00000000..3b53471c --- /dev/null +++ b/core/src/host/feishu-inbound.js @@ -0,0 +1,210 @@ +// Feishu inbound over the long connection, first half (#1266): a message the owner types in Feishu +// starts a turn here. The socket itself is not in this module. Everything that decides is: the +// default-off switch, the owner gate, the bounded `event_id` ring, and the connection state the +// Settings row reads — all of it over an injected transport, so the dependency question (the +// official SDK versus a hand-written protobuf client) changes one adapter and nothing else. +// +// Two things this module deliberately does not decide: +// - how a Feishu conversation is *drawn* here. Phase 2 lands the message through +// `remote-chat.deliver` (`core/src/host/remote-chat.js`), whose `SOURCE_KIND`/`REMOTE_FORM` are +// Bot Social's shape; a Feishu chat rendered as "a friend's Bot" is a Settings question that is +// open, not something to settle inside a transport. +// - how the socket reconnects. A transport that drops hands the same `event_id` in again, and the +// ring is what makes that land once; speaking twice is the transport's send policy, not ours. +// +// The message from Feishu is untrusted text from outside the team, and `deliver` already fences it. + +// A reconnect replays a tail of recent events. This is how far back that tail may reach: a small +// ring, dropped oldest first, never a growing set and never persisted with a secret in it. +export const DEDUPE_RING = 512 +// `off` before the owner turns it on, `connecting` while the socket dials, `ready` once open, and +// `error` with one redacted line: the four values phase 1's row already uses for a connector. +export const STATES = ['off', 'connecting', 'ready', 'error'] + +// Feishu's event envelope: `header.event_id` and `header.event_type`, then `event.sender.sender_id.open_id` +// and `event.message.{chat_id, message_type, content}`, where `content` is a JSON *string*. +// Anything else — a v1 shape, a card callback, an edited message — reads as null and is logged, not +// guessed at: a connector that invents a message out of an unknown body is worse than one that says +// it does not know it. +export function readEvent(envelope) { + const header = envelope?.header + const message = envelope?.event?.message + const sender = envelope?.event?.sender?.sender_id + if (typeof header?.event_id !== 'string' || header.event_id === '') return null + if (typeof message?.chat_id !== 'string' || message.chat_id === '') return null + if (typeof sender?.open_id !== 'string') return null + if (message.message_type !== 'text') return null + let text + try { + text = JSON.parse(String(message.content ?? '')).text + } catch { + return null + } + if (typeof text !== 'string' || text.trim() === '') return null + // A text message mentions people as `@_user_1` placeholders; the handle map that resolves them is + // not something the owner's own 1:1 chat needs, so the placeholders are dropped rather than shown. + return { id: header.event_id, openId: sender.open_id, chatId: message.chat_id, text: text.replace(/@_user_\d+\s*/g, '').trim() } +} + +// Phase 1 spent one secret per connector (`FEISHU_WEBHOOK`, redacted by secrets.js). The long +// connection needs an app, which is a pair: this reads both through the same `rt.secretValue` and +// answers undefined unless both are present, so a half-configured app dials nothing instead of +// failing at the handshake. The two values never leave this function: the view reports `missing`, +// never the id or the secret. +export const APP_ID_SECRET = 'FEISHU_APP_ID' +export const APP_SECRET_SECRET = 'FEISHU_APP_SECRET' + +export function appCredentials(secretValue) { + const id = secretValue?.(APP_ID_SECRET) + const secret = secretValue?.(APP_SECRET_SECRET) + const asText = value => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined) + const appId = asText(id) + const appSecret = asText(secret) + if (appId === undefined || appSecret === undefined) return undefined + return { appId, appSecret } +} + +export const handleOf = chatId => `feishu:${chatId}` + +// `transport` is the only piece the dependency decision can change: +// { connect(onEvent) -> Promise<{ close(): Promise|void }>, nameOf(openId) -> Promise } +// It may also be a function answering that object, in which case it is called again on every +// `start()`: an adapter that is installed after this module still comes up, and one replaced while +// the connection is off is the one the next start dials. +// `deliver({ handle, text, name })` is `remote-chat`'s seam, and `redact` is `rt.redactSecrets`. +export function createInbound({ + enabled = false, + ownerOpenId, + transport, + deliver, + log = () => {}, + redact = text => text, + now = () => Date.now(), + ring = DEDUPE_RING, +} = {}) { + const seen = new Set() + const order = [] + // Events being handed over right now. Two copies of one event can arrive while the first is still + // in `deliver`; the ring is written only after that resolves (#1266 INC), so this is what keeps the + // second copy from starting a second turn in the gap. + const inFlight = new Set() + const view = { state: enabled ? 'connecting' : 'off', connected: 0, duplicates: 0, refused: 0, ignored: 0 } + let closer + let starting + // Bumped by `stop()`: a dial that was already in flight when the owner stopped the connector is + // not the socket this module wants open any more, so the one that arrives is hung up instead of + // adopted, and the row does not flip back to `ready` behind the owner's back. + let generation = 0 + + const socketOf = () => (typeof transport === 'function' ? transport() : transport) + + const remember = (id) => { + if (seen.has(id)) return false + seen.add(id) + order.push(id) + if (order.length > ring) seen.delete(order.shift()) + return true + } + + // The owner is the only sender that may start a turn. An unset or empty id refuses everyone: a + // missing owner and a missing `open_id` must never compare equal. + const owner = () => (typeof ownerOpenId === 'string' && ownerOpenId !== '' ? ownerOpenId : undefined) + + async function nameOf(openId) { + if (typeof transport?.nameOf !== 'function') return undefined + try { + const name = await transport.nameOf(openId) + return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined + } catch { + return undefined + } + } + + async function handleEvent(envelope) { + const read = readEvent(envelope) + if (read === null) { + view.ignored += 1 + log('a Feishu event carried no readable text message; no turn started') + return 'ignored' + } + const expected = owner() + if (expected === undefined || read.openId !== expected) { + view.refused += 1 + log('a Feishu message from %s is not the owner; no turn started', read.openId) + return 'refused' + } + if (seen.has(read.id) || inFlight.has(read.id)) { + view.duplicates += 1 + log('a Feishu event %s was already handled; duplicate ignored', read.id) + return 'duplicate' + } + inFlight.add(read.id) + try { + const name = (await nameOf(read.openId)) ?? handleOf(read.chatId) + await deliver({ handle: handleOf(read.chatId), text: read.text, name }) + } finally { + inFlight.delete(read.id) + } + // Only now is the id handled: a `deliver` that threw leaves it out of the ring, so a replayed + // event is handed over again instead of being called a duplicate for ever (Reviewer, #1266). + remember(read.id) + view.lastEventAt = new Date(now()).toISOString() + return 'delivered' + } + + // One socket per connector. A second start while the first dials shares it, so a reconnect loop + // cannot become two members of the same conversation. + function start() { + if (!enabled) return Promise.resolve('off') + if (starting !== undefined) return starting + const socket = socketOf() + // No adapter yet is a configuration state, not a failed dial: nothing is remembered, so a later + // start — a person pressing Try again, once the adapter is there — still dials instead of reading + // the same error back forever. + if (typeof socket?.connect !== 'function') { + view.state = 'error' + view.error = 'no long-connection transport is installed' + log('the Feishu long connection has no transport: %s', view.error) + return Promise.resolve('error') + } + const mine = generation + const attempt = (async () => { + try { + const opened = await socket.connect(handleEvent) + if (mine !== generation) { + // `stop()` ran while this socket dialled. Hang up what arrived; report it neither ready nor + // as another connection. + await Promise.resolve().then(() => opened?.close?.()).catch(() => { /* an already-dead socket is not an error */ }) + return 'stopped' + } + closer = opened + view.state = 'ready' + view.connected += 1 + delete view.error + return 'ready' + } catch (error) { + if (mine !== generation) return 'stopped' + view.state = 'error' + view.error = redact([error, error?.cause].filter(Boolean).map(part => String(part?.message ?? part)).join(': ') || 'connect failed').slice(0, 300) + log('the Feishu long connection failed: %s', view.error) + return 'error' + } + })() + starting = attempt + // A failed dial is not shared either: the next start tries again with whatever is installed then. + void attempt.then(result => { if (starting === attempt && result !== 'ready') starting = undefined }) + return attempt + } + + async function stop() { + generation += 1 + starting = undefined + const closing = closer + closer = undefined + view.state = enabled ? 'connecting' : 'off' + if (closing !== undefined) await Promise.resolve().then(() => closing.close()).catch(() => { /* an already-dead socket is not an error */ }) + } + + const viewOf = () => ({ state: view.state, ...(view.error === undefined ? {} : { error: view.error }), ...(view.lastEventAt === undefined ? {} : { lastEventAt: view.lastEventAt }), connected: view.connected, duplicates: view.duplicates, refused: view.refused }) + return { start, stop, handleEvent, view: viewOf, seen: () => seen.size } +} diff --git a/core/src/host/feishu-sdk.js b/core/src/host/feishu-sdk.js new file mode 100644 index 00000000..b9f53096 --- /dev/null +++ b/core/src/host/feishu-sdk.js @@ -0,0 +1,80 @@ +// The long-connection adapter over Feishu's own SDK (#1266). This file is the whole of the +// dependency decision: `feishu-inbound.js` keeps the policy and takes a transport, and this adapts +// the SDK's WebSocket client to that `connect(onEvent) -> { close }` shape. A hand-written protobuf +// client would replace this module and nothing else. +// +// The package is imported inside `connect`, never at the top: a host that never turns the long +// connection on, and every test that injects a stub socket, must run without it installed. +// +// `readEvent` reads Feishu's own `{ header, event }` envelope, but the SDK flattens a v2 event +// before it reaches a handler — `event_id` sits beside `message` — so the envelope is rebuilt here +// rather than teaching the policy layer a second shape. +export const SDK_PACKAGE = '@larksuiteoapi/node-sdk' +// A socket that never opens must not hang a start: the row says `error` instead. +export const READY_TIMEOUT_MS = 15_000 + +const within = (promise, ms, message) => { + let timer + const deadline = new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms) + }) + return Promise.race([promise, deadline]).finally(() => clearTimeout(timer)) +} + +export function createFeishuTransport({ + credentials, + importSdk = () => import(SDK_PACKAGE), + log = () => {}, + readyTimeoutMs = READY_TIMEOUT_MS, +} = {}) { + return { + // The app pair is read on every dial, not when the adapter is made: the owner saves it on the + // Secrets page at any time, and a pair that is not there refuses before any import or socket. + async connect(onEvent) { + const app = credentials?.() + if (app === undefined) throw new Error('A Feishu app id and app secret are needed before the long connection can dial') + let sdk + try { + sdk = await importSdk() + } catch (error) { + const why = String(error?.message ?? error).split('\n')[0] + throw new Error(`The Feishu long connection needs ${SDK_PACKAGE} installed: ${why}`) + } + const Lark = sdk?.default ?? sdk + const { WSClient, EventDispatcher } = Lark ?? {} + if (typeof WSClient !== 'function' || typeof EventDispatcher !== 'function') throw new Error(`${SDK_PACKAGE} does not export the long-connection client`) + + const dispatcher = new EventDispatcher({}).register({ + 'im.message.receive_v1': data => onEvent({ + header: { event_id: data?.event_id, event_type: 'im.message.receive_v1' }, + event: { sender: data?.sender, message: data?.message }, + }), + }) + let settle + const opened = new Promise((resolve, reject) => { settle = { resolve, reject } }) + const client = new WSClient({ + appId: app.appId, + appSecret: app.appSecret, + // `onReady` is the first open; a later reconnect is not a second inbox, so it only logs. + onReady: () => settle.resolve(), + onError: error => settle.reject(error), + onReconnecting: () => log('the Feishu long connection is reconnecting'), + }) + try { + await client.start({ eventDispatcher: dispatcher }) + await within(opened, readyTimeoutMs, 'the Feishu long connection did not open') + } catch (error) { + try { client.close({ force: true }) } catch { /* nothing was opened */ } + throw error + } + let closed = false + return { + close: () => { + if (closed) return + closed = true + try { client.close({ force: true }) } catch { /* an already-dead socket is not an error */ } + }, + } + }, + } +} diff --git a/core/test/connectors.test.js b/core/test/connectors.test.js index b256ca55..36d7b486 100644 --- a/core/test/connectors.test.js +++ b/core/test/connectors.test.js @@ -6,8 +6,8 @@ import { syncBuiltinESMExports } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -import { apply } from '../index.js' -import { cleanApps, composioAppStatus, composioDeleteSession, composioSearchApps, composioSession, composioSessionAlive, composioSessionFind, signIn } from '../src/host/connectors.js' +import { apply, createRuntime } from '../index.js' +import { cleanApps, composioAppStatus, composioDeleteSession, composioSearchApps, composioSession, composioSessionAlive, composioSessionFind, feishuReply, signIn } from '../src/host/connectors.js' import { fakeContext } from './fake-ctx.js' // A test's temp home can still be written by a background save that outlives dispose(), so a @@ -416,6 +416,104 @@ test('deleting COMPOSIO_API_KEY on the Secrets page unmounts Composio, and GitHu assert.equal(mounts[1].disposed, true) assert.equal(mounts[0].disposed, false) assert.deepEqual(view.map(entry => [entry.id, entry.state]), [['github', 'ready'], ['composio', 'off'], ['feishu', 'off']]) + // #1266: the Feishu row carries the inbound state as well, and with no app pair saved it says so + // without ever showing a value. Nothing dialled: the long connection is off by default. + assert.deepEqual(view.find(entry => entry.id === 'feishu').inbound, { state: 'off', connected: 0, duplicates: 0, refused: 0, app: 'missing' }) + +}) + +// #1266: the inbound socket is read from the runtime on every start, not copied while module install +// runs. This is the shape a plugin (or a test) has when it puts its own adapter on the runtime after +// the connectors module was installed: the next Try again has to dial that one. +test('an adapter installed after the connectors module is the one the next Try again dials (#1266)', async (t) => { + const home = teamHome() + const ctx = fakeContext() + capture(ctx) + const rt = createRuntime(ctx, { home, feishu: { enabled: true, ownerOpenId: 'ou_owner_1' } }) + t.after(async () => { await ctx.dispose(); await cleanHome(home) }) + + const inboundOf = async () => (await call(ctx, 'state', {})).value.connectors.find(entry => entry.id === 'feishu').inbound + // Boot dialled the default adapter, which refuses because no app pair is saved: the error proves a + // socket was attempted, and that it was not the stub installed below. + const until = Date.now() + 2000 + let boot = await inboundOf() + while (boot.error === undefined && Date.now() < until) { + await new Promise(resolve => setTimeout(resolve, 10)) + boot = await inboundOf() + } + assert.match(boot.error ?? '', /app id and app secret/, JSON.stringify(boot)) + + const dials = [] + rt.feishuTransport = { connect: async (onEvent) => { dials.push(onEvent); return { close: async () => {} } } } + const after = (await call(ctx, 'connector-retry', { id: 'feishu' })).value.find(entry => entry.id === 'feishu') + assert.equal(after.inbound.state, 'ready') + assert.equal(after.inbound.connected, 1) + assert.equal(dials.length, 1, 'the adapter installed late is the one that dialled') +}) + +// #1266 INC (Reviewer): an answer to a Feishu message had no way out. `remote-chat.forward` keys its +// sender by `bot.remote.plugin`, and this module opened the chat without one, so every answer read +// "Not sent: the plugin that reaches this Bot is not running." The chat now carries the feishu plugin. +const FEISHU_INBOUND_EVENT = { + header: { event_id: 'e1', event_type: 'im.message.receive_v1' }, + event: { + sender: { sender_id: { open_id: 'ou_owner_1' } }, + message: { chat_id: 'oc_chat_1', message_type: 'text', content: JSON.stringify({ text: 'hello there' }) }, + }, +} + +// The inbound socket of a running runtime, dialled through Try again so the adapter is a stub: what +// comes back is the `onEvent` the policy layer handed the socket. +async function feishuInboundRig(t) { + const home = teamHome() + const ctx = fakeContext() + capture(ctx) + const rt = createRuntime(ctx, { home, feishu: { enabled: true, ownerOpenId: 'ou_owner_1' } }) + t.after(async () => { await ctx.dispose(); await cleanHome(home) }) + const dials = [] + rt.feishuTransport = { connect: async (onEvent) => { dials.push(onEvent); return { close: async () => {} } } } + await call(ctx, 'connector-retry', { id: 'feishu' }) + assert.ok(dials.length > 0, 'the stub socket dialled') + return { rt, ctx, home, onEvent: dials.at(-1) } +} + +test('a Feishu message opens its chat on the feishu plugin (#1266)', async (t) => { + const { rt, onEvent } = await feishuInboundRig(t) + const delivered = [] + rt.remoteChats.open = async () => 'chat' + rt.remoteChats.deliver = async message => { delivered.push(message); return 'chat' } + await onEvent(FEISHU_INBOUND_EVENT) + assert.equal(delivered.length, 1, 'the message reached the chat seam') + assert.equal(delivered[0].handle, 'feishu:oc_chat_1') + assert.equal(delivered[0].text, 'hello there') + assert.equal(delivered[0].plugin, 'feishu', 'without the plugin the chat forwards its answers to no sender') +}) + +test('the answer sender is registered on the chat seam when a Feishu message arrives (#1266)', async (t) => { + const { rt, onEvent } = await feishuInboundRig(t) + const senders = new Map() + rt.remoteChats.open = async () => 'chat' + rt.remoteChats.deliver = async () => 'chat' + rt.remoteChats.onSend = (plugin, fn) => { senders.set(plugin, fn); return () => { senders.delete(plugin) } } + await onEvent(FEISHU_INBOUND_EVENT) + assert.equal(typeof senders.get('feishu'), 'function', 'remote-chat installs after this module, so the sender is registered when the first message lands') +}) + +test('the answer to a Feishu message leaves on the webhook phase 1 already posts to (#1266)', async () => { + const posts = [] + const reply = feishuReply({ + secretValue: name => (name === 'FEISHU_WEBHOOK' ? 'https://open.feishu.cn/open-apis/bot/v2/hook/abc' : undefined), + post: async (webhook, text) => { posts.push([webhook, text]); return { ok: true } }, + }) + await reply({ handle: 'feishu:oc_chat_1', text: 'the answer' }) + assert.deepEqual(posts, [['https://open.feishu.cn/open-apis/bot/v2/hook/abc', 'the answer']]) + // A missing webhook is a sentence the chat shows, not a silent drop: `remote-chat` prints it as + // the "Not sent:" line it already knows. + await assert.rejects(feishuReply({ secretValue: () => undefined })({ text: 'x' }), /^Error: Not sent: connect Feishu first/) + await assert.rejects( + feishuReply({ secretValue: () => 'https://open.feishu.cn/open-apis/bot/v2/hook/abc', post: async () => ({ ok: false, error: 'feishu/http-400' }) })({ text: 'x' }), + /Not sent: Feishu refused the answer \(feishu\/http-400\)/, + ) }) test('a Composio address without https refuses to send the key', async (t) => { diff --git a/core/test/feishu-inbound.test.js b/core/test/feishu-inbound.test.js new file mode 100644 index 00000000..9f9e56cc --- /dev/null +++ b/core/test/feishu-inbound.test.js @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { APP_ID_SECRET, APP_SECRET_SECRET, DEDUPE_RING, appCredentials, createInbound, readEvent } from '../src/host/feishu-inbound.js' + +// #1266 phase 2: a message in Feishu starts a turn here, over the long connection. The socket is not +// part of this module — every test injects one — so what is asserted is the policy a DSH upgrade or a +// stranger's message would break: one turn per event, the owner only, nothing dialled while off. + +const OWNER = 'ou_owner_1' + +const event = ({ id = 'e1', openId = OWNER, chatId = 'oc_chat_1', text = 'hello there', type = 'text', content } = {}) => ({ + header: { event_id: id, event_type: 'im.message.receive_v1' }, + event: { + sender: { sender_id: { open_id: openId } }, + message: { chat_id: chatId, message_type: type, content: content ?? JSON.stringify({ text }) }, + }, +}) + +function setup(options = {}) { + // `'ownerOpenId' in options` matters: an explicit `undefined` is the case "the owner was never + // configured", which must not fall back to the default the other tests use. + const { enabled = true, connect, nameOf } = options + const ownerOpenId = 'ownerOpenId' in options ? options.ownerOpenId : OWNER + const turns = [] + const logs = [] + const dials = [] + const transport = { + connect: connect ?? (async (onEvent) => { dials.push(onEvent); return { close: async () => {} } }), + nameOf: nameOf ?? (async () => 'Owner'), + } + const inbound = createInbound({ + enabled, + ownerOpenId, + transport, + deliver: async message => { turns.push(message) }, + log: (...args) => logs.push(args.map(String).join(' ')), + redact: text => String(text).split('app-secret').join('[redacted]'), + now: () => Date.parse('2026-10-26T10:00:00Z'), + }) + return { inbound, turns, logs, dials } +} + +test('a replayed event_id starts one turn, and the second one says duplicate (#1266)', async () => { + const { inbound, turns, logs } = setup() + await inbound.start() + assert.equal(await inbound.handleEvent(event()), 'delivered') + assert.equal(await inbound.handleEvent(event()), 'duplicate') + assert.equal(turns.length, 1) + assert.equal(turns[0].handle, 'feishu:oc_chat_1') + assert.equal(turns[0].text, 'hello there') + assert.match(logs.join('\n'), /duplicate/) + assert.equal(inbound.view().duplicates, 1) +}) + +test('a ring this size still forgets the oldest id, so a fresh event is not refused (#1266)', async () => { + const { inbound, turns } = setup() + await inbound.start() + await inbound.handleEvent(event({ id: 'ring-oldest' })) + for (let n = 0; n < DEDUPE_RING; n += 1) await inbound.handleEvent(event({ id: `filler-${n}` })) + assert.equal(turns.length, DEDUPE_RING + 1) + assert.equal(inbound.seen(), DEDUPE_RING) + // The oldest id left the ring, so a replay of it lands again: bounded memory is the priced choice. + assert.equal(await inbound.handleEvent(event({ id: 'ring-oldest' })), 'delivered') + assert.equal(turns.length, DEDUPE_RING + 2) +}) + +test('a message from anyone but the owner starts no turn (#1266)', async () => { + const { inbound, turns, logs } = setup() + await inbound.start() + assert.equal(await inbound.handleEvent(event({ openId: 'ou_someone_else' })), 'refused') + assert.equal(turns.length, 0) + assert.match(logs.join('\n'), /not the owner/) + assert.equal(inbound.view().refused, 1) +}) + +test('no owner and no open_id never compare equal (#1266)', async () => { + const unset = setup({ ownerOpenId: undefined }) + await unset.inbound.start() + assert.notEqual(await unset.inbound.handleEvent(event()), 'delivered') + assert.equal(unset.turns.length, 0) + const blank = setup({ ownerOpenId: '' }) + await blank.inbound.start() + assert.notEqual(await blank.inbound.handleEvent(event({ openId: '' })), 'delivered') + assert.equal(blank.turns.length, 0) +}) + +test('switched off, it dials nothing at all (#1266)', async () => { + const { inbound, dials } = setup({ enabled: false }) + assert.equal(await inbound.start(), 'off') + assert.equal(dials.length, 0) + assert.equal(inbound.view().state, 'off') +}) + +test('a failed connect is one redacted line in the row, not a thrown start (#1266)', async () => { + const { inbound } = setup({ + connect: async () => { throw new Error('handshake refused: app-secret=app-secret') }, + }) + assert.equal(await inbound.start(), 'error') + const view = inbound.view() + assert.equal(view.state, 'error') + assert.match(view.error, /handshake refused/) + assert.doesNotMatch(view.error, /app-secret/) + assert.match(view.error, /\[redacted\]/) +}) + +test('one socket per connector: a second start while it dials shares the first (#1266)', async () => { + const { inbound, dials } = setup() + const [a, b] = await Promise.all([inbound.start(), inbound.start()]) + assert.equal(a, 'ready') + assert.equal(b, 'ready') + assert.equal(dials.length, 1) + assert.equal(inbound.view().connected, 1) +}) + +test('an event that is not a text message is logged and starts nothing (#1266)', async () => { + const { inbound, turns, logs } = setup() + await inbound.start() + assert.equal(await inbound.handleEvent(event({ type: 'image' })), 'ignored') + assert.equal(await inbound.handleEvent(event({ content: 'not json' })), 'ignored') + assert.equal(await inbound.handleEvent({ header: { event_id: 'e9' } }), 'ignored') + assert.equal(turns.length, 0) + assert.match(logs.join('\n'), /no readable text message/) +}) + +test('a text message drops the mention placeholders and names the chat after the conversation (#1266)', async () => { + const { inbound, turns } = setup({ nameOf: async () => ' Someone ' }) + await inbound.start() + await inbound.handleEvent(event({ text: '@_user_1 ping the desk' })) + assert.equal(turns[0].text, 'ping the desk') + assert.equal(turns[0].name, 'Someone') + const unnamed = setup({ nameOf: async () => { throw new Error('no profile read') } }) + await unnamed.inbound.start() + await unnamed.inbound.handleEvent(event({ chatId: 'oc_named_later' })) + assert.equal(unnamed.turns[0].name, 'feishu:oc_named_later') +}) + +test('readEvent reads only the envelope phase 2 means to accept (#1266)', () => { + assert.deepEqual(readEvent(event({ id: 'e5', chatId: 'oc_5', text: 'a line' })), { id: 'e5', openId: OWNER, chatId: 'oc_5', text: 'a line' }) + assert.equal(readEvent({}), null) + assert.equal(readEvent(event({ content: '{}' })), null) + assert.equal(readEvent(event({ text: ' ' })), null) + assert.equal(readEvent({ header: { event_id: 'e6' }, event: { sender: { sender_id: { open_id: OWNER } }, message: { chat_id: 'oc', message_type: 'text', content: '{"text":"x"}' } } }) !== null, true) +}) + +test('the app pair is one credential: either half missing dials nothing (#1266)', () => { + const secrets = { [APP_ID_SECRET]: 'cli_app_1', [APP_SECRET_SECRET]: 'app-secret-value' } + const read = name => secrets[name] + assert.deepEqual(appCredentials(read), { appId: 'cli_app_1', appSecret: 'app-secret-value' }) + assert.equal(appCredentials(name => (name === APP_ID_SECRET ? undefined : secrets[name])), undefined) + assert.equal(appCredentials(name => (name === APP_SECRET_SECRET ? ' ' : secrets[name])), undefined) + assert.equal(appCredentials(undefined), undefined) +}) + +test('the view never carries either half of the app pair (#1266)', async () => { + const secrets = { [APP_ID_SECRET]: 'cli_app_1', [APP_SECRET_SECRET]: 'app-secret-value' } + const seen = [] + const transport = { + connect: async () => { throw new Error(`handshake refused for ${secrets[APP_ID_SECRET]} / ${secrets[APP_SECRET_SECRET]}`) }, + } + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport, + deliver: async message => { seen.push(message) }, + redact: text => String(text).split(secrets[APP_SECRET_SECRET]).join('[redacted]').split(secrets[APP_ID_SECRET]).join('[redacted]'), + }) + await inbound.start() + const view = JSON.stringify(inbound.view()) + assert.match(view, /handshake refused/) + assert.doesNotMatch(view, /app-secret-value/) + assert.doesNotMatch(view, /cli_app_1/) + assert.equal(seen.length, 0) +}) + +test('with no socket adapter installed it says so instead of pretending (#1266)', async () => { + const inbound = createInbound({ enabled: true, ownerOpenId: OWNER, deliver: async () => {} }) + assert.equal(await inbound.start(), 'error') + assert.deepEqual(inbound.view().error, 'no long-connection transport is installed') + assert.equal(inbound.view().connected, 0) +}) + +test('an adapter installed after the first start is the one the next start dials (#1266)', async () => { + // The policy is made before the adapter exists — the shape a module that installs its socket later + // has. A missing adapter must not be remembered as the answer. + const transport = { current: undefined } + const dials = [] + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: () => transport.current, + deliver: async () => {}, + }) + assert.equal(await inbound.start(), 'error') + assert.equal(inbound.view().error, 'no long-connection transport is installed') + transport.current = { connect: async onEvent => { dials.push(onEvent); return { close: async () => {} } } } + assert.equal(await inbound.start(), 'ready') + assert.equal(dials.length, 1, 'the adapter that arrived late is the one that dialled') + assert.equal(inbound.view().connected, 1) +}) + +test('a stop while the socket dials hangs up the one that arrives and never reports ready (#1266)', async () => { + let release + const dialling = new Promise(resolve => { release = resolve }) + const closed = [] + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: { + connect: async () => { await dialling; return { close: async () => { closed.push(1) } } }, + }, + deliver: async () => {}, + }) + const started = inbound.start() + await inbound.stop() + release() + assert.equal(await started, 'stopped', 'the dial that outlived stop is not adopted') + assert.deepEqual(closed, [1], 'the socket that arrived after stop is hung up, not leaked') + assert.equal(inbound.view().state, 'connecting', 'the row does not flip back to ready behind the owner') + assert.equal(inbound.view().connected, 0) +}) + +// #1266 INC (Reviewer): the ring used to be written *before* `deliver`, so a delivery that threw +// left the id in it and every replay read `duplicate` — a lost message the owner could never get +// back. The id now enters the ring only once the message has been handed to its chat. +test('a message whose delivery throws is handed over again, not remembered as handled (#1266)', async () => { + const turns = [] + let refuse = true + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: { connect: async () => ({ close: async () => {} }) }, + deliver: async message => { + if (refuse) { + refuse = false + throw new Error('no chat seam is installed for a Feishu message') + } + turns.push(message) + }, + }) + await inbound.start() + await assert.rejects(inbound.handleEvent(event()), /no chat seam is installed/) + assert.equal(await inbound.handleEvent(event()), 'delivered', 'the same event_id is handled again') + assert.equal(turns.length, 1) + assert.equal(inbound.view().duplicates, 0, 'a failed delivery is not a duplicate') + assert.equal(inbound.seen(), 1, 'the ring holds the id once, after the delivery succeeded') +}) + +test('two copies of one event arriving together still start one turn (#1266)', async () => { + const turns = [] + let release + const handed = new Promise(resolve => { release = resolve }) + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: { connect: async () => ({ close: async () => {} }) }, + deliver: async message => { await handed; turns.push(message) }, + }) + await inbound.start() + const first = inbound.handleEvent(event()) + const second = inbound.handleEvent(event()) + release() + const results = await Promise.all([first, second]) + assert.deepEqual(results.sort(), ['delivered', 'duplicate']) + assert.equal(turns.length, 1) +}) diff --git a/core/test/feishu-sdk.test.js b/core/test/feishu-sdk.test.js new file mode 100644 index 00000000..4faa21c8 --- /dev/null +++ b/core/test/feishu-sdk.test.js @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { readEvent } from '../src/host/feishu-inbound.js' +import { SDK_PACKAGE, createFeishuTransport } from '../src/host/feishu-sdk.js' + +// #1266: the one adapter that needs Feishu's own SDK. What is asserted here is the seam, not the +// network: the package is imported only when a socket is dialled, the SDK's flattened v2 event is +// handed to the policy as Feishu's own envelope, and a socket that fails or never opens ends the +// start with a line the row can show. + +const APP = { appId: 'cli_0123456789abcdef', appSecret: 'app-secret-value' } + +// A stand-in for the package: it records what the adapter asked it to do. +function fakeSdk({ open = true } = {}) { + const made = [] + class EventDispatcher { + register(handles) { this.handles = handles; return this } + } + class WSClient { + constructor(params) { this.params = params; this.closed = []; made.push(this) } + start({ eventDispatcher }) { + this.handles = eventDispatcher.handles + if (open) this.params.onReady?.() + return Promise.resolve() + } + close(params) { this.closed.push(params ?? {}) } + } + return { made, sdk: { WSClient, EventDispatcher } } +} + +const doesNotImport = () => { throw new Error('the package must not be imported here') } + +test('the SDK is imported at dial time, and its flattened event reaches the policy as Feishu sends it (#1266)', async () => { + const { made, sdk } = fakeSdk() + let imported = 0 + const seen = [] + const transport = createFeishuTransport({ + credentials: () => APP, + importSdk: async () => { imported += 1; return sdk }, + }) + assert.equal(imported, 0, 'nothing is imported before a socket is dialled') + const closer = await transport.connect(envelope => { seen.push(envelope) }) + assert.equal(imported, 1) + assert.equal(made[0].params.appId, APP.appId) + assert.equal(made[0].params.appSecret, APP.appSecret) + + // The SDK merges `header` and `event` into one object (`event_id` beside `message`); the adapter + // rebuilds the envelope `readEvent` reads, so the policy keeps one shape. + await made[0].handles['im.message.receive_v1']({ + event_id: 'e1', + sender: { sender_id: { open_id: 'ou_owner' } }, + message: { chat_id: 'oc_1', message_type: 'text', content: '{"text":"hello there"}' }, + }) + assert.deepEqual(readEvent(seen[0]), { id: 'e1', openId: 'ou_owner', chatId: 'oc_1', text: 'hello there' }) + + closer.close() + assert.deepEqual(made[0].closed, [{ force: true }]) + closer.close() + assert.equal(made[0].closed.length, 1, 'closing twice hangs up once') +}) + +test('a package that is not installed is one readable line, not a stack (#1266)', async () => { + const transport = createFeishuTransport({ + credentials: () => APP, + importSdk: async () => { throw new Error(`Cannot find package '${SDK_PACKAGE}' imported from core`) }, + }) + await assert.rejects(transport.connect(async () => {}), /needs @larksuiteoapi\/node-sdk installed: Cannot find package/) +}) + +test('with no app pair saved nothing is imported and nothing dials (#1266)', async () => { + const transport = createFeishuTransport({ credentials: () => undefined, importSdk: doesNotImport }) + await assert.rejects(transport.connect(async () => {}), /app id and app secret are needed/) +}) + +test('a socket that reports an error ends the start with that error (#1266)', async () => { + class WSClient { + constructor(params) { this.params = params } + start() { this.params.onError(new Error('handshake refused')) } + close() {} + } + class EventDispatcher { register(handles) { this.handles = handles; return this } } + const transport = createFeishuTransport({ credentials: () => APP, importSdk: async () => ({ WSClient, EventDispatcher }) }) + await assert.rejects(transport.connect(async () => {}), /handshake refused/) +}) + +test('a socket that never opens is given up at the deadline instead of hanging the start (#1266)', async () => { + const { made, sdk } = fakeSdk({ open: false }) + const transport = createFeishuTransport({ credentials: () => APP, importSdk: async () => sdk, readyTimeoutMs: 10 }) + await assert.rejects(transport.connect(async () => {}), /did not open/) + assert.equal(made[0].closed.length, 1, 'the half-open client is hung up') +}) diff --git a/core/test/feishu.test.js b/core/test/feishu.test.js index f0755e2a..149f5322 100644 --- a/core/test/feishu.test.js +++ b/core/test/feishu.test.js @@ -61,6 +61,9 @@ async function call(ctx, endpoint, payload) { } const row = (view, id = 'feishu') => (view?.connectors ?? view ?? []).find(entry => entry.id === id) +// #1266: the same row now carries the inbound state (off, no socket, app pair not saved). It is +// spelled out here instead of spread, so a value leaking into the row cannot pass unnoticed. +const INBOUND_OFF = { state: 'off', connected: 0, duplicates: 0, refused: 0, app: 'missing' } const said = (warns, needle) => warns.filter(args => args.some(value => String(value).includes(needle))) // A fake Feishu that records each POST and answers from the script (the last answer repeats). @@ -78,7 +81,7 @@ test('#1263: Feishu is a sender row of its own, off until a webhook is pasted', const { ctx, home } = setup() t.after(async () => { await ctx.dispose(); cleanHome(home) }) const state = await call(ctx, 'state', {}) - assert.deepEqual(row(state.value), { id: 'feishu', name: 'Feishu', state: 'off', kind: 'sender', account: null }) + assert.deepEqual(row(state.value), { id: 'feishu', name: 'Feishu', state: 'off', kind: 'sender', account: null, inbound: INBOUND_OFF }) // A sender has no tools, so it is not a name a saved capability may take either way. assert.deepEqual(state.value.connectors.map(entry => entry.id), ['github', 'composio', 'feishu']) }) @@ -94,7 +97,7 @@ test('#1263: connecting posts one test message, turns ready, and keeps the webho const connected = await call(ctx, 'connector-connect', { id: 'feishu', token: ` ${webhook} ` }) assert.equal(connected.ok, true, JSON.stringify(connected)) - assert.deepEqual(row(connected.value), { id: 'feishu', name: 'Feishu', state: 'ready', kind: 'sender', account: null }) + assert.deepEqual(row(connected.value), { id: 'feishu', name: 'Feishu', state: 'ready', kind: 'sender', account: null, inbound: INBOUND_OFF }) assert.equal(feishu.posts.length, 1, 'one test POST on connect') assert.equal(feishu.posts[0].url, webhook) diff --git a/core/test/remote-chat.test.js b/core/test/remote-chat.test.js index 4f5902ed..39b95d85 100644 --- a/core/test/remote-chat.test.js +++ b/core/test/remote-chat.test.js @@ -2,6 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { remoteTurn, isRemoteChat, REMOTE_PROVIDER, install, notSentLine } from '../src/host/remote-chat.js'; import { isHomeLevelRemote, isListedRemoteChat, isShownRemoteChat } from '../src/shared/remote-label.js'; +import { feishuReply } from '../src/host/connectors.js'; const user = (content, source, id = 'm1') => ({ role: 'user', id, content, ...(source ? { source } : {}) }); @@ -282,3 +283,24 @@ test('a line Bot Social attests the friend typed themselves carries the person\' assert.equal(followups[0].source.form, 'remote-chat') assert.equal(followups[1].source.text, 'bot line') }) + +// #1266 INC (Reviewer): the whole exit of a Feishu answer, composed. A `feishu:` chat carries the +// feishu plugin, the plugin's sender is the one `connectors.js` registers, and a message typed into +// that chat leaves on the webhook — not as "Not sent: the plugin that reaches this Bot is not running." +test('a reply typed into a feishu chat leaves on the Feishu webhook (#1266)', async () => { + const state = { bots: { f1: { id: 'f1', name: '飞书 Owner', remote: { handle: 'feishu:oc_chat_1', plugin: 'feishu' } } } } + let adapter + const posts = [] + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: { llm: { registerAdapter: (_, a) => { adapter = a } } } } + install(rt) + rt.remoteChats.onSend('feishu', feishuReply({ + secretValue: name => (name === 'FEISHU_WEBHOOK' ? 'https://open.feishu.cn/open-apis/bot/v2/hook/abc' : undefined), + post: async (webhook, text) => { posts.push([webhook, text]); return { ok: true } }, + })) + let said = '' + for await (const event of adapter.stream({ model: 'feishu:oc_chat_1', messages: [{ role: 'user', id: 'm1', content: [{ type: 'text', text: 'the answer' }] }] })) { + if (event.type === 'text-delta') said += event.text + } + assert.equal(said, '', 'nothing is drawn for a send that left') + assert.deepEqual(posts, [['https://open.feishu.cn/open-apis/bot/v2/hook/abc', 'the answer']]) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 44cfb75a..5b6a6809 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -337,6 +337,8 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi Feishu (Lark) is the first **sender** connector (#1263): a group's custom-bot webhook is one HTTPS POST into the instance's own group, so nothing is mounted, no Bot's tool list changes, and `connector-access.js`'s `CONNECTOR_IDS` does not list it. Its definition carries `kind: 'sender'`, which the view reports and the client reads instead of hard-coding an id. A sender stops at the status: a saved webhook reads `ready` without posting, because the test POST belongs to the **connect** step, so a restart never messages the group; `connector-retry` ("Try again") is the one path that proves it again. The webhook is `FEISHU_WEBHOOK`, saved through `secret-set` **before** the probe — so a refusal stays on the row as `error` with the reason and `redactSecrets` can mask it, the way a failed mount does for GitHub — and it must be https on the configured `feishu.api` origin, else `A Feishu webhook must be https on `: the URL is the credential, so it may never be posted to another host and never appears in `state.json`, the view or a warn line. `connector-send { id, text }` posts one message on demand and records `lastOkAt` / `lastError` in `state.connectors.feishu`; nothing posts room traffic by itself. Feishu answers HTTP 200 with the verdict in the body (`code: 0` success, `9499` a rate limit, `19021` a refused signature), so `feishuPost` maps the body into notify.js's vocabulary and the retry policy stays `notify.js`'s `createNotifier` (3 attempts, backoff, honour `retry-after`, structural errors not retried) rather than a second one written here; `connector-send` uses a fresh idempotency key per call, so two messages with the same text are two messages. The connect probe and a send both carry a deadline (15 s and 60 s) and `withDeadline` races the fetch, so a fetch that ignores its abort signal cannot hang either. `feishu: false` hides the row; `connector-send` is in `SHARED_BLOCKED` like the other connector writes. +The same row carries a Feishu message coming **in** (#1266): `inbound` reports `off|connecting|ready|error`, the last event, the last error and whether the app pair is `set` or `missing` — never a value. `src/host/feishu-inbound.js` owns the policy: off unless `feishu.enabled` is true (an off connector dials nothing), the owner is the only sender that may start a turn (an unset or empty `feishu.ownerOpenId` refuses everyone, and an empty `open_id` never compares equal), a bounded ring of 512 `event_id`s makes a replay land once — the id enters it only after `deliver` has returned, so a handover that threw is retried instead of being refused as a duplicate for ever — and one socket per connector shares a start. The socket itself is injected as `rt.feishuTransport` (`connect(onEvent) -> { close }`) so the client behind it is one adapter, and it is read again on every start rather than copied while the modules install — an adapter that arrives later comes up on the row's **Try again**, which retries the sender and the socket together; a stop that lands while a socket dials hangs up the one that arrives instead of adopting it. `src/host/feishu-sdk.js` is the default adapter over the official `@larksuiteoapi/node-sdk`, imported only when a socket is dialled and reading the app pair at that moment, so a host that never turns the long connection on pays nothing for the package; the message lands through `remote-chat.deliver`, the seam a friend's Bot already uses, and the answer leaves again through the sender this module registers in that seam under the `feishu` plugin, posting on the same `FEISHU_WEBHOOK` `feishuPost` has used since phase 1 (a chat an older build opened without the plugin is refreshed on its next message) — one path in, one path out. `FEISHU_APP_ID` + `FEISHU_APP_SECRET` are read as one credential through the same `rt.secretValue`, and a half-saved pair reports `missing` and dials nothing. + ### Secret scope defaults (#545) A key the owner saves is for **all of the owner's Bots** (`scope: 'all'`) unless the owner narrows it: a card's "Only ", the Secrets page's *Chosen Bots* or *No Bot*. That holds for a connector key too (`GITHUB_TOKEN`, `COMPOSIO_API_KEY`): `connector-connect` no longer passes `scope: []`, and a reconnect keeps the scope the key already has. A scope the owner chose is stored with `scopeSet: true` (`secret-update`, `secret-set` with a `scope`, a card's "Only ", and a deleted Bot leaving a picked list), so an empty list stays "No Bot". On load, an empty list **without** `scopeSet` is the old connector default and becomes `'all'` once (written back by the host that holds the team; a reading host widens it in memory only). An empty list the owner picked before #545 carries no marker and is widened too: there is no way to tell it apart. **Every turn of every one of the owner's own Bots** gets the keys its scope allows (owner decision 2026-10-22, #601: 「放开这个权限管理,我的bot都应该能看到」). Where the turn came from no longer matters for secrets: a group round, a Bot-to-Bot relay, a scheduled turn, a turn a friend started on a shared Bot, a turn with no recorded origin, and bash inside a sub-agent all get them. A sub-agent's shell resolves to its parent Bot along the same chain as the connector guard (`rt.whoseAgent`, from `connector-access.js`); a chain that cannot be followed to a Bot gets no value. The boundaries that stay: a friend's Bot (`remote`) never gets the owner's keys, and a key scoped to chosen Bots goes only to those Bots. A capability (`canUseSecret`) follows the same rule. This replaces the turn-origin rule of #545/#581, under which a shell got `'all'` keys only on a turn that `settled` as the owner's. The connector *tools* follow the same rule (#601, below); secrets no longer read `turn-origin.js`. Owner decision 2026-10-20 (#359, AGENTS.md hard rule 4). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index fa3a3270..fea679ad 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2601,7 +2601,7 @@ diff --git a/src/host/chat-index.js b/src/host/chat-index.js diff --git a/src/host/connectors.js b/src/host/connectors.js --- a/src/host/connectors.js +++ b/src/host/connectors.js -@@ -1,27 +1,55 @@ +@@ -1,27 +1,57 @@ -// Connectors: outside services every Bot can use. GitHub is the first one. Its tools come -// from GitHub's MCP server through DSH's own MCP client, mounted on the plugin context, -// so every Bot and Session gets them as mcp__github__. @@ -2631,6 +2631,8 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + +import { randomUUID } from 'node:crypto' +import { createNotifier } from './notify.js' ++import { appCredentials, createInbound } from './feishu-inbound.js' ++import { createFeishuTransport } from './feishu-sdk.js' export const GITHUB = 'github' export const GITHUB_SECRET = 'GITHUB_TOKEN' @@ -2669,7 +2671,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js let response try { response = await request(`${api.replace(/\/$/, '')}/user`, { -@@ -38,132 +38,653 @@ export async function signIn(token, { api = DEFAULTS.api, fetch: request = fetch +@@ -38,132 +38,729 @@ export async function signIn(token, { api = DEFAULTS.api, fetch: request = fetch return { login: user.login } } @@ -2923,6 +2925,20 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + return { postMessage: message => feishuPost(webhook, message?.text ?? '', { fetch: request, timeoutMs }) } +} + ++// The way out for an answer to a Feishu message (#1266): the webhook phase 1 already posts on, so a ++// message in and an answer out are one credential and one transport. Kept out of `install` so the ++// exit can be asserted without a socket. A missing webhook or a refusal is a sentence the chat ++// shows ("Not sent: …"), never a silent drop: `remote-chat` prints exactly what is thrown here. ++export function feishuReply({ secretValue, post = feishuPost, timeoutMs = SEND_TIMEOUT_MS } = {}) { ++ return async ({ text }) => { ++ const webhook = secretValue?.(FEISHU_SECRET) ++ if (webhook === undefined) throw new Error('Not sent: connect Feishu first, so the answer has a webhook to leave on.') ++ const answer = await post(webhook, String(text ?? ''), { timeoutMs }) ++ if (answer?.ok === true) return ++ throw new Error(`Not sent: Feishu refused the answer (${answer?.error ?? 'no answer'})`) ++ } ++} ++ +const DEFINITIONS = { + [GITHUB]: { + id: GITHUB, @@ -3031,14 +3047,62 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + } + }) + ++ // Feishu inbound (#1266): a message in Feishu starts a turn here. The socket is injected — ++ // `rt.feishuTransport` is the one adapter the SDK-versus-hand-written decision changes, so nothing ++ // below depends on which client dials. Off unless the owner turned it on, and with no socket adapter ++ // it starts nothing rather than pretend. The message lands through `remote-chat.deliver` — the same ++ // seam a friend's Bot uses — and the answer leaves through the sender this module registers in that ++ // same seam, on the webhook phase 1 posts on: one path in, one path out. ++ // ++ // The default adapter is the official SDK, and it is put on the runtime *before* the policy reads ++ // it. That read happens on every `start()` rather than once here, so an adapter installed later ++ // (a test's stub, a plugin's own client) is the one the next start dials instead of a snapshot ++ // taken while it was missing. ++ if (rt.feishuTransport === undefined) { ++ rt.feishuTransport = createFeishuTransport({ ++ credentials: () => appCredentials(name => rt.secretValue(name)), ++ log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), ++ }) ++ } ++ // One way out per Feishu chat: `remote-chat` keys a reply's sender by the chat's plugin, so this ++ // registers the webhook post under `feishu`. Registered against the seam object it saw, so a runtime ++ // that replaces the seam is wired again instead of keeping a dead registration. ++ let feishuWire = { seam: undefined, off: undefined } ++ const wireFeishuReply = () => { ++ const seam = rt.remoteChats ++ if (typeof seam?.onSend !== 'function') return ++ if (feishuWire.seam === seam) return ++ feishuWire.off?.() ++ feishuWire = { seam, off: seam.onSend(FEISHU, feishuReply({ secretValue: name => rt.secretValue(name) })) } ++ } ++ ++ const feishuInbound = createInbound({ ++ enabled: rt.config?.feishu?.enabled === true, ++ ownerOpenId: rt.config?.feishu?.ownerOpenId, ++ transport: () => rt.feishuTransport, ++ deliver: async ({ handle, text, name }) => { ++ const seam = rt.remoteChats ++ if (typeof seam?.deliver !== 'function') throw new Error('no chat seam is installed for a Feishu message') ++ // The seam is installed after this module, so the answer's sender is registered when the first ++ // message lands rather than while module install runs. ++ wireFeishuReply() ++ // `plugin` is what `remote-chat.forward` keys the sender by. Without it the chat is opened with ++ // `remote.plugin === undefined` and every answer reads "Not sent: the plugin that reaches this ++ // Bot is not running." `open` refreshes a chat that an older build opened without the plugin. ++ if (typeof seam.open === 'function') await seam.open({ handle, name, plugin: FEISHU }) ++ await seam.deliver({ handle, text, name, plugin: FEISHU }) ++ }, ++ log: (format, ...args) => rt.warn(`ds-bot: ${format}`, ...args), ++ redact: rt.redactSecrets, ++ }) ++ + const saved = c => state.connectors?.[c.def.id] + const unmount = async (c) => { + const fiber = c.mount?.fiber + c.mount = null + if (fiber !== undefined) await Promise.resolve().then(() => fiber.dispose()).catch(() => { /* a fiber that fails to dispose must not block unloading */ }) - } - -- // The MCP client wraps the server's answer (a 401, say) as the cause. ++ } ++ + // A sender's retry policy is notify.js's, not a second one written here: one transport per + // sender, reading the saved webhook when it is called, so a reconnect needs no rebuild. + const notifiers = new Map(connectors.filter(c => c.def.kind === 'sender').map(c => [c.def.id, createNotifier({ @@ -3064,8 +3128,9 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + c.status = { state: 'error', error: reason(error) } + rt.warn('ds-bot: %s connector failed: %s', c.def.name, c.status.error) + } -+ } -+ + } + +- // The MCP client wraps the server's answer (a 401, say) as the cause. + // The MCP client wraps the server's answer (a 401, say) as the cause. The session address of a + // mount in progress is not kept anywhere, so it is also taken out of what is shown or logged. + let addresses = [] @@ -3091,16 +3156,16 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js - status = { state: 'off' } + notifierOf(c)?.setEnabled(false) + c.status = { state: 'off' } ++ return ++ } ++ if (c.insecure) { ++ c.mount = { key } ++ c.status = { state: 'error', error: INSECURE(def.name) } return } - if (insecure) { - mount = { token } - status = { state: 'error', error: INSECURE } -+ if (c.insecure) { -+ c.mount = { key } -+ c.status = { state: 'error', error: INSECURE(def.name) } -+ return -+ } + c.status = { state: 'connecting' } + // A sender mounts nothing: the test POST belongs to the connect step, so a start-up (or a + // secret change) only reports that a webhook is saved — a restart must not post to the group @@ -3188,6 +3253,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + ...(c.def.kind !== undefined ? { kind: c.def.kind } : {}), + account: rt.secretValue(c.def.secret) !== undefined ? c.def.account(saved(c)) : null, + ...(c.def.extra !== undefined ? c.def.extra(rt.secretValue(c.def.secret) !== undefined ? saved(c) : undefined) : {}), ++ ...(c.def.id === FEISHU ? { inbound: { ...feishuInbound.view(), app: appCredentials(name => rt.secretValue(name)) === undefined ? 'missing' : 'set' } } : {}), + }) + const connectorsView = () => connectors.filter(c => c.options !== null).map(view) @@ -3257,8 +3323,8 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + } + await retire(c, { gone: previous?.session, kept: record.session, keys: [oldToken, token] }) + await sync(c) - return connectorsView() - }, ++ return connectorsView() ++ }, + // Changes which apps a connected gateway offers, without asking for the key again. + async 'connector-apps'(body) { + const c = known(body.id) @@ -3277,8 +3343,8 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + } + await retire(c, { gone: previous?.session, kept: record.session, keys: [token] }) + await sync(c) -+ return connectorsView() -+ }, + return connectorsView() + }, + // Searches Composio's app list: with the saved key, or before connecting with the key being pasted + // (sent only to Composio, never saved by this call). + async 'connector-app-search'(body) { @@ -3335,9 +3401,12 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js async 'connector-retry'(body) { - known(body.id) + const c = known(body.id) - await rt.loadSecrets() -- await sync(true) ++ await rt.loadSecrets() + await sync(c, true) ++ // "Try again" is one press for the whole row: a Feishu row proves the webhook and, when the ++ // long connection is switched on, dials it again — which is how an adapter installed after the ++ // first attempt comes up without a restart. ++ if (c.def.id === FEISHU) await feishuInbound.start() + return connectorsView() + }, + // One message, on demand, once. Nothing posts room traffic on its own: that is a spam @@ -3348,7 +3417,8 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + const text = typeof body.text === 'string' ? body.text.trim() : '' + if (text === '') throw new Error('Type a message first') + if (c.insecure) throw new Error(INSECURE(c.def.name)) -+ await rt.loadSecrets() + await rt.loadSecrets() +- await sync(true) + if (rt.secretValue(c.def.secret) === undefined) throw new Error(`Connect ${c.def.name} first`) + const notifier = notifierOf(c) + // A fresh key per call: two messages with the same text are two messages, not a duplicate. @@ -3376,10 +3446,18 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js ctx.effect(() => { stopped = false - void rt.loadSecrets().then(() => sync()) -+ void scrubAddresses().catch(error => rt.warn('ds-bot: connectors: %s', reason(error))).then(() => rt.loadSecrets()).then(syncAll) ++ // The long connection dials once the secrets are loaded: the app pair lives there, so starting ++ // before that read would report a missing app that is in fact saved. ++ void scrubAddresses().catch(error => rt.warn('ds-bot: connectors: %s', reason(error))).then(() => rt.loadSecrets()).then(async () => { ++ await syncAll() ++ if (!stopped) await feishuInbound.start() ++ }) return () => { stopped = true - void unmount() ++ feishuWire.off?.() ++ feishuWire = { seam: undefined, off: undefined } ++ void feishuInbound.stop() + for (const c of connectors) void unmount(c) } }, 'ds-bot: connectors') @@ -6197,9 +6275,10 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' - import { apply } from '../index.js' +-import { apply } from '../index.js' -import { signIn } from '../src/host/connectors.js' -+import { cleanApps, composioAppStatus, composioDeleteSession, composioSearchApps, composioSession, composioSessionAlive, composioSessionFind, signIn } from '../src/host/connectors.js' ++import { apply, createRuntime } from '../index.js' ++import { cleanApps, composioAppStatus, composioDeleteSession, composioSearchApps, composioSession, composioSessionAlive, composioSessionFind, feishuReply, signIn } from '../src/host/connectors.js' import { fakeContext } from './fake-ctx.js' +// A test's temp home can still be written by a background save that outlives dispose(), so a @@ -6376,7 +6455,7 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js }) test('a GitHub address without https refuses to send the token', async (t) => { -@@ -165,10 +165,614 @@ test('a GitHub address without https refuses to send the token', async (t) => { +@@ -165,10 +165,712 @@ test('a GitHub address without https refuses to send the token', async (t) => { let asked = false globalThis.fetch = async () => { asked = true; return answer(200, { login: 'octo' }) } const { ctx, home, mounts } = setup({ github: { url: 'http://mcp.test/', api: 'https://gh.test' } }) @@ -6581,6 +6660,104 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js + assert.equal(mounts[1].disposed, true) + assert.equal(mounts[0].disposed, false) + assert.deepEqual(view.map(entry => [entry.id, entry.state]), [['github', 'ready'], ['composio', 'off'], ['feishu', 'off']]) ++ // #1266: the Feishu row carries the inbound state as well, and with no app pair saved it says so ++ // without ever showing a value. Nothing dialled: the long connection is off by default. ++ assert.deepEqual(view.find(entry => entry.id === 'feishu').inbound, { state: 'off', connected: 0, duplicates: 0, refused: 0, app: 'missing' }) ++ ++}) ++ ++// #1266: the inbound socket is read from the runtime on every start, not copied while module install ++// runs. This is the shape a plugin (or a test) has when it puts its own adapter on the runtime after ++// the connectors module was installed: the next Try again has to dial that one. ++test('an adapter installed after the connectors module is the one the next Try again dials (#1266)', async (t) => { ++ const home = teamHome() ++ const ctx = fakeContext() ++ capture(ctx) ++ const rt = createRuntime(ctx, { home, feishu: { enabled: true, ownerOpenId: 'ou_owner_1' } }) ++ t.after(async () => { await ctx.dispose(); await cleanHome(home) }) ++ ++ const inboundOf = async () => (await call(ctx, 'state', {})).value.connectors.find(entry => entry.id === 'feishu').inbound ++ // Boot dialled the default adapter, which refuses because no app pair is saved: the error proves a ++ // socket was attempted, and that it was not the stub installed below. ++ const until = Date.now() + 2000 ++ let boot = await inboundOf() ++ while (boot.error === undefined && Date.now() < until) { ++ await new Promise(resolve => setTimeout(resolve, 10)) ++ boot = await inboundOf() ++ } ++ assert.match(boot.error ?? '', /app id and app secret/, JSON.stringify(boot)) ++ ++ const dials = [] ++ rt.feishuTransport = { connect: async (onEvent) => { dials.push(onEvent); return { close: async () => {} } } } ++ const after = (await call(ctx, 'connector-retry', { id: 'feishu' })).value.find(entry => entry.id === 'feishu') ++ assert.equal(after.inbound.state, 'ready') ++ assert.equal(after.inbound.connected, 1) ++ assert.equal(dials.length, 1, 'the adapter installed late is the one that dialled') ++}) ++ ++// #1266 INC (Reviewer): an answer to a Feishu message had no way out. `remote-chat.forward` keys its ++// sender by `bot.remote.plugin`, and this module opened the chat without one, so every answer read ++// "Not sent: the plugin that reaches this Bot is not running." The chat now carries the feishu plugin. ++const FEISHU_INBOUND_EVENT = { ++ header: { event_id: 'e1', event_type: 'im.message.receive_v1' }, ++ event: { ++ sender: { sender_id: { open_id: 'ou_owner_1' } }, ++ message: { chat_id: 'oc_chat_1', message_type: 'text', content: JSON.stringify({ text: 'hello there' }) }, ++ }, ++} ++ ++// The inbound socket of a running runtime, dialled through Try again so the adapter is a stub: what ++// comes back is the `onEvent` the policy layer handed the socket. ++async function feishuInboundRig(t) { ++ const home = teamHome() ++ const ctx = fakeContext() ++ capture(ctx) ++ const rt = createRuntime(ctx, { home, feishu: { enabled: true, ownerOpenId: 'ou_owner_1' } }) ++ t.after(async () => { await ctx.dispose(); await cleanHome(home) }) ++ const dials = [] ++ rt.feishuTransport = { connect: async (onEvent) => { dials.push(onEvent); return { close: async () => {} } } } ++ await call(ctx, 'connector-retry', { id: 'feishu' }) ++ assert.ok(dials.length > 0, 'the stub socket dialled') ++ return { rt, ctx, home, onEvent: dials.at(-1) } ++} ++ ++test('a Feishu message opens its chat on the feishu plugin (#1266)', async (t) => { ++ const { rt, onEvent } = await feishuInboundRig(t) ++ const delivered = [] ++ rt.remoteChats.open = async () => 'chat' ++ rt.remoteChats.deliver = async message => { delivered.push(message); return 'chat' } ++ await onEvent(FEISHU_INBOUND_EVENT) ++ assert.equal(delivered.length, 1, 'the message reached the chat seam') ++ assert.equal(delivered[0].handle, 'feishu:oc_chat_1') ++ assert.equal(delivered[0].text, 'hello there') ++ assert.equal(delivered[0].plugin, 'feishu', 'without the plugin the chat forwards its answers to no sender') ++}) ++ ++test('the answer sender is registered on the chat seam when a Feishu message arrives (#1266)', async (t) => { ++ const { rt, onEvent } = await feishuInboundRig(t) ++ const senders = new Map() ++ rt.remoteChats.open = async () => 'chat' ++ rt.remoteChats.deliver = async () => 'chat' ++ rt.remoteChats.onSend = (plugin, fn) => { senders.set(plugin, fn); return () => { senders.delete(plugin) } } ++ await onEvent(FEISHU_INBOUND_EVENT) ++ assert.equal(typeof senders.get('feishu'), 'function', 'remote-chat installs after this module, so the sender is registered when the first message lands') ++}) ++ ++test('the answer to a Feishu message leaves on the webhook phase 1 already posts to (#1266)', async () => { ++ const posts = [] ++ const reply = feishuReply({ ++ secretValue: name => (name === 'FEISHU_WEBHOOK' ? 'https://open.feishu.cn/open-apis/bot/v2/hook/abc' : undefined), ++ post: async (webhook, text) => { posts.push([webhook, text]); return { ok: true } }, ++ }) ++ await reply({ handle: 'feishu:oc_chat_1', text: 'the answer' }) ++ assert.deepEqual(posts, [['https://open.feishu.cn/open-apis/bot/v2/hook/abc', 'the answer']]) ++ // A missing webhook is a sentence the chat shows, not a silent drop: `remote-chat` prints it as ++ // the "Not sent:" line it already knows. ++ await assert.rejects(feishuReply({ secretValue: () => undefined })({ text: 'x' }), /^Error: Not sent: connect Feishu first/) ++ await assert.rejects( ++ feishuReply({ secretValue: () => 'https://open.feishu.cn/open-apis/bot/v2/hook/abc', post: async () => ({ ok: false, error: 'feishu/http-400' }) })({ text: 'x' }), ++ /Not sent: Feishu refused the answer \(feishu\/http-400\)/, ++ ) +}) + +test('a Composio address without https refuses to send the key', async (t) => { diff --git a/patches/ds-bot/0007-version-and-tooling.patch b/patches/ds-bot/0007-version-and-tooling.patch index c3cd3ad9..4ed7f874 100644 --- a/patches/ds-bot/0007-version-and-tooling.patch +++ b/patches/ds-bot/0007-version-and-tooling.patch @@ -52,7 +52,7 @@ diff --git a/dev/smoke.mjs b/dev/smoke.mjs diff --git a/package-lock.json b/package-lock.json --- a/package-lock.json +++ b/package-lock.json -@@ -1,12 +1,12 @@ +@@ -1,13 +1,16 @@ { "name": "ds-bot", - "version": "0.1.5", @@ -65,8 +65,628 @@ diff --git a/package-lock.json b/package-lock.json - "version": "0.1.5", + "version": "0.2.9", "license": "Apache-2.0", ++ "dependencies": { ++ "@larksuiteoapi/node-sdk": "1.76.0" ++ }, "devDependencies": { "esbuild": "0.28.2" + }, +@@ -460,6 +460,243 @@ + "node": ">=18" + } + }, ++ "node_modules/@larksuiteoapi/node-sdk": { ++ "version": "1.76.0", ++ "resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.76.0.tgz", ++ "integrity": "sha512-v/bxQNCugwAmMPbZtBGJtSu8hdQLiB1/29Jr+GBIG18DyQ4LYyp7gxQ74pkhXVSuoUeH4v+/ZHAw/SW+KMrj2g==", ++ "license": "MIT", ++ "dependencies": { ++ "axios": "^1.16.0", ++ "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" ++ } ++ }, ++ "node_modules/@protobufjs/aspromise": { ++ "version": "1.1.2", ++ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", ++ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/base64": { ++ "version": "1.1.2", ++ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", ++ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/codegen": { ++ "version": "2.0.5", ++ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", ++ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/eventemitter": { ++ "version": "1.1.1", ++ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", ++ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/fetch": { ++ "version": "1.1.1", ++ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", ++ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", ++ "license": "BSD-3-Clause", ++ "dependencies": { ++ "@protobufjs/aspromise": "^1.1.1" ++ } ++ }, ++ "node_modules/@protobufjs/float": { ++ "version": "1.0.2", ++ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", ++ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/path": { ++ "version": "1.1.2", ++ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", ++ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/pool": { ++ "version": "1.1.0", ++ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", ++ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@protobufjs/utf8": { ++ "version": "1.1.2", ++ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", ++ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", ++ "license": "BSD-3-Clause" ++ }, ++ "node_modules/@types/node": { ++ "version": "26.6.5", ++ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", ++ "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", ++ "license": "MIT", ++ "dependencies": { ++ "undici-types": "~8.9.0" ++ } ++ }, ++ "node_modules/agent-base": { ++ "version": "6.0.2", ++ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", ++ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", ++ "license": "MIT", ++ "dependencies": { ++ "debug": "4" ++ }, ++ "engines": { ++ "node": ">= 6.0.0" ++ } ++ }, ++ "node_modules/asynckit": { ++ "version": "0.4.0", ++ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", ++ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", ++ "license": "MIT" ++ }, ++ "node_modules/axios": { ++ "version": "1.20.0", ++ "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", ++ "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", ++ "license": "MIT", ++ "dependencies": { ++ "follow-redirects": "^1.16.0", ++ "form-data": "^4.0.6", ++ "https-proxy-agent": "^5.0.1", ++ "proxy-from-env": "^2.1.0" ++ } ++ }, ++ "node_modules/call-bind-apply-helpers": { ++ "version": "1.0.2", ++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", ++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", ++ "license": "MIT", ++ "dependencies": { ++ "es-errors": "^1.3.0", ++ "function-bind": "^1.1.2" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/call-bound": { ++ "version": "1.0.4", ++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", ++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", ++ "license": "MIT", ++ "dependencies": { ++ "call-bind-apply-helpers": "^1.0.2", ++ "get-intrinsic": "^1.3.0" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/combined-stream": { ++ "version": "1.0.8", ++ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", ++ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", ++ "license": "MIT", ++ "dependencies": { ++ "delayed-stream": "~1.0.0" ++ }, ++ "engines": { ++ "node": ">= 0.8" ++ } ++ }, ++ "node_modules/debug": { ++ "version": "4.4.3", ++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", ++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", ++ "license": "MIT", ++ "dependencies": { ++ "ms": "^2.1.3" ++ }, ++ "engines": { ++ "node": ">=6.0" ++ }, ++ "peerDependenciesMeta": { ++ "supports-color": { ++ "optional": true ++ } ++ } ++ }, ++ "node_modules/delayed-stream": { ++ "version": "1.0.0", ++ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", ++ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", ++ "license": "MIT", ++ "engines": { ++ "node": ">=0.4.0" ++ } ++ }, ++ "node_modules/dunder-proto": { ++ "version": "1.0.1", ++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", ++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", ++ "license": "MIT", ++ "dependencies": { ++ "call-bind-apply-helpers": "^1.0.1", ++ "es-errors": "^1.3.0", ++ "gopd": "^1.2.0" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/es-define-property": { ++ "version": "1.0.1", ++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", ++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/es-errors": { ++ "version": "1.3.0", ++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", ++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/es-object-atoms": { ++ "version": "1.1.2", ++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", ++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", ++ "license": "MIT", ++ "dependencies": { ++ "es-errors": "^1.3.0" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/es-set-tostringtag": { ++ "version": "2.1.0", ++ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", ++ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", ++ "license": "MIT", ++ "dependencies": { ++ "es-errors": "^1.3.0", ++ "get-intrinsic": "^1.2.6", ++ "has-tostringtag": "^1.0.2", ++ "hasown": "^2.0.2" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", +@@ -501,6 +501,371 @@ + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } ++ }, ++ "node_modules/follow-redirects": { ++ "version": "1.16.2", ++ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", ++ "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", ++ "funding": [ ++ { ++ "type": "individual", ++ "url": "https://github.com/sponsors/RubenVerborgh" ++ } ++ ], ++ "license": "MIT", ++ "engines": { ++ "node": ">=4.0" ++ }, ++ "peerDependenciesMeta": { ++ "debug": { ++ "optional": true ++ } ++ } ++ }, ++ "node_modules/form-data": { ++ "version": "4.0.6", ++ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", ++ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", ++ "license": "MIT", ++ "dependencies": { ++ "asynckit": "^0.4.0", ++ "combined-stream": "^1.0.8", ++ "es-set-tostringtag": "^2.1.0", ++ "hasown": "^2.0.4", ++ "mime-types": "^2.1.35" ++ }, ++ "engines": { ++ "node": ">= 6" ++ } ++ }, ++ "node_modules/function-bind": { ++ "version": "1.1.2", ++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", ++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", ++ "license": "MIT", ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/get-intrinsic": { ++ "version": "1.3.0", ++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", ++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", ++ "license": "MIT", ++ "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" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/get-proto": { ++ "version": "1.0.1", ++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", ++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", ++ "license": "MIT", ++ "dependencies": { ++ "dunder-proto": "^1.0.1", ++ "es-object-atoms": "^1.0.0" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/gopd": { ++ "version": "1.2.0", ++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", ++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/has-symbols": { ++ "version": "1.1.0", ++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", ++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/has-tostringtag": { ++ "version": "1.0.2", ++ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", ++ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", ++ "license": "MIT", ++ "dependencies": { ++ "has-symbols": "^1.0.3" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/hasown": { ++ "version": "2.0.4", ++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", ++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", ++ "license": "MIT", ++ "dependencies": { ++ "function-bind": "^1.1.2" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/https-proxy-agent": { ++ "version": "5.0.1", ++ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", ++ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", ++ "license": "MIT", ++ "dependencies": { ++ "agent-base": "6", ++ "debug": "4" ++ }, ++ "engines": { ++ "node": ">= 6" ++ } ++ }, ++ "node_modules/lodash.identity": { ++ "version": "3.0.0", ++ "resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz", ++ "integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==", ++ "license": "MIT" ++ }, ++ "node_modules/lodash.merge": { ++ "version": "4.6.2", ++ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", ++ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", ++ "license": "MIT" ++ }, ++ "node_modules/lodash.pickby": { ++ "version": "4.6.0", ++ "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz", ++ "integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==", ++ "license": "MIT" ++ }, ++ "node_modules/long": { ++ "version": "5.3.2", ++ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", ++ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", ++ "license": "Apache-2.0" ++ }, ++ "node_modules/math-intrinsics": { ++ "version": "1.1.0", ++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", ++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.4" ++ } ++ }, ++ "node_modules/mime-db": { ++ "version": "1.52.0", ++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", ++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.6" ++ } ++ }, ++ "node_modules/mime-types": { ++ "version": "2.1.35", ++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", ++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", ++ "license": "MIT", ++ "dependencies": { ++ "mime-db": "1.52.0" ++ }, ++ "engines": { ++ "node": ">= 0.6" ++ } ++ }, ++ "node_modules/ms": { ++ "version": "2.1.3", ++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", ++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", ++ "license": "MIT" ++ }, ++ "node_modules/object-inspect": { ++ "version": "1.13.4", ++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", ++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/protobufjs": { ++ "version": "7.6.6", ++ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", ++ "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", ++ "hasInstallScript": true, ++ "license": "BSD-3-Clause", ++ "dependencies": { ++ "@protobufjs/aspromise": "^1.1.2", ++ "@protobufjs/base64": "^1.1.2", ++ "@protobufjs/codegen": "^2.0.5", ++ "@protobufjs/eventemitter": "^1.1.1", ++ "@protobufjs/fetch": "^1.1.1", ++ "@protobufjs/float": "^1.0.2", ++ "@protobufjs/path": "^1.1.2", ++ "@protobufjs/pool": "^1.1.0", ++ "@protobufjs/utf8": "^1.1.1", ++ "@types/node": ">=13.7.0", ++ "long": "^5.3.2" ++ }, ++ "engines": { ++ "node": ">=12.0.0" ++ } ++ }, ++ "node_modules/proxy-from-env": { ++ "version": "2.1.0", ++ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", ++ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", ++ "license": "MIT", ++ "engines": { ++ "node": ">=10" ++ } ++ }, ++ "node_modules/qs": { ++ "version": "6.16.1", ++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", ++ "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", ++ "license": "BSD-3-Clause", ++ "dependencies": { ++ "es-define-property": "^1.0.1", ++ "side-channel": "^1.1.1" ++ }, ++ "engines": { ++ "node": ">=0.6" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/side-channel": { ++ "version": "1.1.1", ++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", ++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", ++ "license": "MIT", ++ "dependencies": { ++ "es-errors": "^1.3.0", ++ "object-inspect": "^1.13.4", ++ "side-channel-list": "^1.0.1", ++ "side-channel-map": "^1.0.1", ++ "side-channel-weakmap": "^1.0.2" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/side-channel-list": { ++ "version": "1.0.1", ++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", ++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", ++ "license": "MIT", ++ "dependencies": { ++ "es-errors": "^1.3.0", ++ "object-inspect": "^1.13.4" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/side-channel-map": { ++ "version": "1.0.1", ++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", ++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", ++ "license": "MIT", ++ "dependencies": { ++ "call-bound": "^1.0.2", ++ "es-errors": "^1.3.0", ++ "get-intrinsic": "^1.2.5", ++ "object-inspect": "^1.13.3" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/side-channel-weakmap": { ++ "version": "1.0.2", ++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", ++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", ++ "license": "MIT", ++ "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" ++ }, ++ "engines": { ++ "node": ">= 0.4" ++ }, ++ "funding": { ++ "url": "https://github.com/sponsors/ljharb" ++ } ++ }, ++ "node_modules/undici-types": { ++ "version": "8.9.0", ++ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", ++ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", ++ "license": "MIT" ++ }, ++ "node_modules/ws": { ++ "version": "8.22.0", ++ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", ++ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", ++ "license": "MIT", ++ "engines": { ++ "node": ">=10.0.0" ++ }, ++ "peerDependencies": { ++ "bufferutil": "^4.0.1", ++ "utf-8-validate": ">=5.0.2" ++ }, ++ "peerDependenciesMeta": { ++ "bufferutil": { ++ "optional": true ++ }, ++ "utf-8-validate": { ++ "optional": true ++ } ++ } + } + } + } diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -130,6 +750,16 @@ diff --git a/package.json b/package.json }, "peerDependencies": { "@deepseek-ai/cordis": "*", +@@ -52,6 +52,9 @@ + "@deepseek-ai/dsh-tools": "*", + "@deepseek-ai/dsh-web": "*" + }, ++ "dependencies": { ++ "@larksuiteoapi/node-sdk": "1.76.0" ++ }, + "dsh": { + "client": { + "platform": "web" @@ -62,5 +62,6 @@ }, "devDependencies": { diff --git a/patches/ds-bot/files/src/host/feishu-inbound.js b/patches/ds-bot/files/src/host/feishu-inbound.js new file mode 100644 index 00000000..3b53471c --- /dev/null +++ b/patches/ds-bot/files/src/host/feishu-inbound.js @@ -0,0 +1,210 @@ +// Feishu inbound over the long connection, first half (#1266): a message the owner types in Feishu +// starts a turn here. The socket itself is not in this module. Everything that decides is: the +// default-off switch, the owner gate, the bounded `event_id` ring, and the connection state the +// Settings row reads — all of it over an injected transport, so the dependency question (the +// official SDK versus a hand-written protobuf client) changes one adapter and nothing else. +// +// Two things this module deliberately does not decide: +// - how a Feishu conversation is *drawn* here. Phase 2 lands the message through +// `remote-chat.deliver` (`core/src/host/remote-chat.js`), whose `SOURCE_KIND`/`REMOTE_FORM` are +// Bot Social's shape; a Feishu chat rendered as "a friend's Bot" is a Settings question that is +// open, not something to settle inside a transport. +// - how the socket reconnects. A transport that drops hands the same `event_id` in again, and the +// ring is what makes that land once; speaking twice is the transport's send policy, not ours. +// +// The message from Feishu is untrusted text from outside the team, and `deliver` already fences it. + +// A reconnect replays a tail of recent events. This is how far back that tail may reach: a small +// ring, dropped oldest first, never a growing set and never persisted with a secret in it. +export const DEDUPE_RING = 512 +// `off` before the owner turns it on, `connecting` while the socket dials, `ready` once open, and +// `error` with one redacted line: the four values phase 1's row already uses for a connector. +export const STATES = ['off', 'connecting', 'ready', 'error'] + +// Feishu's event envelope: `header.event_id` and `header.event_type`, then `event.sender.sender_id.open_id` +// and `event.message.{chat_id, message_type, content}`, where `content` is a JSON *string*. +// Anything else — a v1 shape, a card callback, an edited message — reads as null and is logged, not +// guessed at: a connector that invents a message out of an unknown body is worse than one that says +// it does not know it. +export function readEvent(envelope) { + const header = envelope?.header + const message = envelope?.event?.message + const sender = envelope?.event?.sender?.sender_id + if (typeof header?.event_id !== 'string' || header.event_id === '') return null + if (typeof message?.chat_id !== 'string' || message.chat_id === '') return null + if (typeof sender?.open_id !== 'string') return null + if (message.message_type !== 'text') return null + let text + try { + text = JSON.parse(String(message.content ?? '')).text + } catch { + return null + } + if (typeof text !== 'string' || text.trim() === '') return null + // A text message mentions people as `@_user_1` placeholders; the handle map that resolves them is + // not something the owner's own 1:1 chat needs, so the placeholders are dropped rather than shown. + return { id: header.event_id, openId: sender.open_id, chatId: message.chat_id, text: text.replace(/@_user_\d+\s*/g, '').trim() } +} + +// Phase 1 spent one secret per connector (`FEISHU_WEBHOOK`, redacted by secrets.js). The long +// connection needs an app, which is a pair: this reads both through the same `rt.secretValue` and +// answers undefined unless both are present, so a half-configured app dials nothing instead of +// failing at the handshake. The two values never leave this function: the view reports `missing`, +// never the id or the secret. +export const APP_ID_SECRET = 'FEISHU_APP_ID' +export const APP_SECRET_SECRET = 'FEISHU_APP_SECRET' + +export function appCredentials(secretValue) { + const id = secretValue?.(APP_ID_SECRET) + const secret = secretValue?.(APP_SECRET_SECRET) + const asText = value => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined) + const appId = asText(id) + const appSecret = asText(secret) + if (appId === undefined || appSecret === undefined) return undefined + return { appId, appSecret } +} + +export const handleOf = chatId => `feishu:${chatId}` + +// `transport` is the only piece the dependency decision can change: +// { connect(onEvent) -> Promise<{ close(): Promise|void }>, nameOf(openId) -> Promise } +// It may also be a function answering that object, in which case it is called again on every +// `start()`: an adapter that is installed after this module still comes up, and one replaced while +// the connection is off is the one the next start dials. +// `deliver({ handle, text, name })` is `remote-chat`'s seam, and `redact` is `rt.redactSecrets`. +export function createInbound({ + enabled = false, + ownerOpenId, + transport, + deliver, + log = () => {}, + redact = text => text, + now = () => Date.now(), + ring = DEDUPE_RING, +} = {}) { + const seen = new Set() + const order = [] + // Events being handed over right now. Two copies of one event can arrive while the first is still + // in `deliver`; the ring is written only after that resolves (#1266 INC), so this is what keeps the + // second copy from starting a second turn in the gap. + const inFlight = new Set() + const view = { state: enabled ? 'connecting' : 'off', connected: 0, duplicates: 0, refused: 0, ignored: 0 } + let closer + let starting + // Bumped by `stop()`: a dial that was already in flight when the owner stopped the connector is + // not the socket this module wants open any more, so the one that arrives is hung up instead of + // adopted, and the row does not flip back to `ready` behind the owner's back. + let generation = 0 + + const socketOf = () => (typeof transport === 'function' ? transport() : transport) + + const remember = (id) => { + if (seen.has(id)) return false + seen.add(id) + order.push(id) + if (order.length > ring) seen.delete(order.shift()) + return true + } + + // The owner is the only sender that may start a turn. An unset or empty id refuses everyone: a + // missing owner and a missing `open_id` must never compare equal. + const owner = () => (typeof ownerOpenId === 'string' && ownerOpenId !== '' ? ownerOpenId : undefined) + + async function nameOf(openId) { + if (typeof transport?.nameOf !== 'function') return undefined + try { + const name = await transport.nameOf(openId) + return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined + } catch { + return undefined + } + } + + async function handleEvent(envelope) { + const read = readEvent(envelope) + if (read === null) { + view.ignored += 1 + log('a Feishu event carried no readable text message; no turn started') + return 'ignored' + } + const expected = owner() + if (expected === undefined || read.openId !== expected) { + view.refused += 1 + log('a Feishu message from %s is not the owner; no turn started', read.openId) + return 'refused' + } + if (seen.has(read.id) || inFlight.has(read.id)) { + view.duplicates += 1 + log('a Feishu event %s was already handled; duplicate ignored', read.id) + return 'duplicate' + } + inFlight.add(read.id) + try { + const name = (await nameOf(read.openId)) ?? handleOf(read.chatId) + await deliver({ handle: handleOf(read.chatId), text: read.text, name }) + } finally { + inFlight.delete(read.id) + } + // Only now is the id handled: a `deliver` that threw leaves it out of the ring, so a replayed + // event is handed over again instead of being called a duplicate for ever (Reviewer, #1266). + remember(read.id) + view.lastEventAt = new Date(now()).toISOString() + return 'delivered' + } + + // One socket per connector. A second start while the first dials shares it, so a reconnect loop + // cannot become two members of the same conversation. + function start() { + if (!enabled) return Promise.resolve('off') + if (starting !== undefined) return starting + const socket = socketOf() + // No adapter yet is a configuration state, not a failed dial: nothing is remembered, so a later + // start — a person pressing Try again, once the adapter is there — still dials instead of reading + // the same error back forever. + if (typeof socket?.connect !== 'function') { + view.state = 'error' + view.error = 'no long-connection transport is installed' + log('the Feishu long connection has no transport: %s', view.error) + return Promise.resolve('error') + } + const mine = generation + const attempt = (async () => { + try { + const opened = await socket.connect(handleEvent) + if (mine !== generation) { + // `stop()` ran while this socket dialled. Hang up what arrived; report it neither ready nor + // as another connection. + await Promise.resolve().then(() => opened?.close?.()).catch(() => { /* an already-dead socket is not an error */ }) + return 'stopped' + } + closer = opened + view.state = 'ready' + view.connected += 1 + delete view.error + return 'ready' + } catch (error) { + if (mine !== generation) return 'stopped' + view.state = 'error' + view.error = redact([error, error?.cause].filter(Boolean).map(part => String(part?.message ?? part)).join(': ') || 'connect failed').slice(0, 300) + log('the Feishu long connection failed: %s', view.error) + return 'error' + } + })() + starting = attempt + // A failed dial is not shared either: the next start tries again with whatever is installed then. + void attempt.then(result => { if (starting === attempt && result !== 'ready') starting = undefined }) + return attempt + } + + async function stop() { + generation += 1 + starting = undefined + const closing = closer + closer = undefined + view.state = enabled ? 'connecting' : 'off' + if (closing !== undefined) await Promise.resolve().then(() => closing.close()).catch(() => { /* an already-dead socket is not an error */ }) + } + + const viewOf = () => ({ state: view.state, ...(view.error === undefined ? {} : { error: view.error }), ...(view.lastEventAt === undefined ? {} : { lastEventAt: view.lastEventAt }), connected: view.connected, duplicates: view.duplicates, refused: view.refused }) + return { start, stop, handleEvent, view: viewOf, seen: () => seen.size } +} diff --git a/patches/ds-bot/files/src/host/feishu-sdk.js b/patches/ds-bot/files/src/host/feishu-sdk.js new file mode 100644 index 00000000..b9f53096 --- /dev/null +++ b/patches/ds-bot/files/src/host/feishu-sdk.js @@ -0,0 +1,80 @@ +// The long-connection adapter over Feishu's own SDK (#1266). This file is the whole of the +// dependency decision: `feishu-inbound.js` keeps the policy and takes a transport, and this adapts +// the SDK's WebSocket client to that `connect(onEvent) -> { close }` shape. A hand-written protobuf +// client would replace this module and nothing else. +// +// The package is imported inside `connect`, never at the top: a host that never turns the long +// connection on, and every test that injects a stub socket, must run without it installed. +// +// `readEvent` reads Feishu's own `{ header, event }` envelope, but the SDK flattens a v2 event +// before it reaches a handler — `event_id` sits beside `message` — so the envelope is rebuilt here +// rather than teaching the policy layer a second shape. +export const SDK_PACKAGE = '@larksuiteoapi/node-sdk' +// A socket that never opens must not hang a start: the row says `error` instead. +export const READY_TIMEOUT_MS = 15_000 + +const within = (promise, ms, message) => { + let timer + const deadline = new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms) + }) + return Promise.race([promise, deadline]).finally(() => clearTimeout(timer)) +} + +export function createFeishuTransport({ + credentials, + importSdk = () => import(SDK_PACKAGE), + log = () => {}, + readyTimeoutMs = READY_TIMEOUT_MS, +} = {}) { + return { + // The app pair is read on every dial, not when the adapter is made: the owner saves it on the + // Secrets page at any time, and a pair that is not there refuses before any import or socket. + async connect(onEvent) { + const app = credentials?.() + if (app === undefined) throw new Error('A Feishu app id and app secret are needed before the long connection can dial') + let sdk + try { + sdk = await importSdk() + } catch (error) { + const why = String(error?.message ?? error).split('\n')[0] + throw new Error(`The Feishu long connection needs ${SDK_PACKAGE} installed: ${why}`) + } + const Lark = sdk?.default ?? sdk + const { WSClient, EventDispatcher } = Lark ?? {} + if (typeof WSClient !== 'function' || typeof EventDispatcher !== 'function') throw new Error(`${SDK_PACKAGE} does not export the long-connection client`) + + const dispatcher = new EventDispatcher({}).register({ + 'im.message.receive_v1': data => onEvent({ + header: { event_id: data?.event_id, event_type: 'im.message.receive_v1' }, + event: { sender: data?.sender, message: data?.message }, + }), + }) + let settle + const opened = new Promise((resolve, reject) => { settle = { resolve, reject } }) + const client = new WSClient({ + appId: app.appId, + appSecret: app.appSecret, + // `onReady` is the first open; a later reconnect is not a second inbox, so it only logs. + onReady: () => settle.resolve(), + onError: error => settle.reject(error), + onReconnecting: () => log('the Feishu long connection is reconnecting'), + }) + try { + await client.start({ eventDispatcher: dispatcher }) + await within(opened, readyTimeoutMs, 'the Feishu long connection did not open') + } catch (error) { + try { client.close({ force: true }) } catch { /* nothing was opened */ } + throw error + } + let closed = false + return { + close: () => { + if (closed) return + closed = true + try { client.close({ force: true }) } catch { /* an already-dead socket is not an error */ } + }, + } + }, + } +} diff --git a/patches/ds-bot/files/test/feishu-inbound.test.js b/patches/ds-bot/files/test/feishu-inbound.test.js new file mode 100644 index 00000000..9f9e56cc --- /dev/null +++ b/patches/ds-bot/files/test/feishu-inbound.test.js @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { APP_ID_SECRET, APP_SECRET_SECRET, DEDUPE_RING, appCredentials, createInbound, readEvent } from '../src/host/feishu-inbound.js' + +// #1266 phase 2: a message in Feishu starts a turn here, over the long connection. The socket is not +// part of this module — every test injects one — so what is asserted is the policy a DSH upgrade or a +// stranger's message would break: one turn per event, the owner only, nothing dialled while off. + +const OWNER = 'ou_owner_1' + +const event = ({ id = 'e1', openId = OWNER, chatId = 'oc_chat_1', text = 'hello there', type = 'text', content } = {}) => ({ + header: { event_id: id, event_type: 'im.message.receive_v1' }, + event: { + sender: { sender_id: { open_id: openId } }, + message: { chat_id: chatId, message_type: type, content: content ?? JSON.stringify({ text }) }, + }, +}) + +function setup(options = {}) { + // `'ownerOpenId' in options` matters: an explicit `undefined` is the case "the owner was never + // configured", which must not fall back to the default the other tests use. + const { enabled = true, connect, nameOf } = options + const ownerOpenId = 'ownerOpenId' in options ? options.ownerOpenId : OWNER + const turns = [] + const logs = [] + const dials = [] + const transport = { + connect: connect ?? (async (onEvent) => { dials.push(onEvent); return { close: async () => {} } }), + nameOf: nameOf ?? (async () => 'Owner'), + } + const inbound = createInbound({ + enabled, + ownerOpenId, + transport, + deliver: async message => { turns.push(message) }, + log: (...args) => logs.push(args.map(String).join(' ')), + redact: text => String(text).split('app-secret').join('[redacted]'), + now: () => Date.parse('2026-10-26T10:00:00Z'), + }) + return { inbound, turns, logs, dials } +} + +test('a replayed event_id starts one turn, and the second one says duplicate (#1266)', async () => { + const { inbound, turns, logs } = setup() + await inbound.start() + assert.equal(await inbound.handleEvent(event()), 'delivered') + assert.equal(await inbound.handleEvent(event()), 'duplicate') + assert.equal(turns.length, 1) + assert.equal(turns[0].handle, 'feishu:oc_chat_1') + assert.equal(turns[0].text, 'hello there') + assert.match(logs.join('\n'), /duplicate/) + assert.equal(inbound.view().duplicates, 1) +}) + +test('a ring this size still forgets the oldest id, so a fresh event is not refused (#1266)', async () => { + const { inbound, turns } = setup() + await inbound.start() + await inbound.handleEvent(event({ id: 'ring-oldest' })) + for (let n = 0; n < DEDUPE_RING; n += 1) await inbound.handleEvent(event({ id: `filler-${n}` })) + assert.equal(turns.length, DEDUPE_RING + 1) + assert.equal(inbound.seen(), DEDUPE_RING) + // The oldest id left the ring, so a replay of it lands again: bounded memory is the priced choice. + assert.equal(await inbound.handleEvent(event({ id: 'ring-oldest' })), 'delivered') + assert.equal(turns.length, DEDUPE_RING + 2) +}) + +test('a message from anyone but the owner starts no turn (#1266)', async () => { + const { inbound, turns, logs } = setup() + await inbound.start() + assert.equal(await inbound.handleEvent(event({ openId: 'ou_someone_else' })), 'refused') + assert.equal(turns.length, 0) + assert.match(logs.join('\n'), /not the owner/) + assert.equal(inbound.view().refused, 1) +}) + +test('no owner and no open_id never compare equal (#1266)', async () => { + const unset = setup({ ownerOpenId: undefined }) + await unset.inbound.start() + assert.notEqual(await unset.inbound.handleEvent(event()), 'delivered') + assert.equal(unset.turns.length, 0) + const blank = setup({ ownerOpenId: '' }) + await blank.inbound.start() + assert.notEqual(await blank.inbound.handleEvent(event({ openId: '' })), 'delivered') + assert.equal(blank.turns.length, 0) +}) + +test('switched off, it dials nothing at all (#1266)', async () => { + const { inbound, dials } = setup({ enabled: false }) + assert.equal(await inbound.start(), 'off') + assert.equal(dials.length, 0) + assert.equal(inbound.view().state, 'off') +}) + +test('a failed connect is one redacted line in the row, not a thrown start (#1266)', async () => { + const { inbound } = setup({ + connect: async () => { throw new Error('handshake refused: app-secret=app-secret') }, + }) + assert.equal(await inbound.start(), 'error') + const view = inbound.view() + assert.equal(view.state, 'error') + assert.match(view.error, /handshake refused/) + assert.doesNotMatch(view.error, /app-secret/) + assert.match(view.error, /\[redacted\]/) +}) + +test('one socket per connector: a second start while it dials shares the first (#1266)', async () => { + const { inbound, dials } = setup() + const [a, b] = await Promise.all([inbound.start(), inbound.start()]) + assert.equal(a, 'ready') + assert.equal(b, 'ready') + assert.equal(dials.length, 1) + assert.equal(inbound.view().connected, 1) +}) + +test('an event that is not a text message is logged and starts nothing (#1266)', async () => { + const { inbound, turns, logs } = setup() + await inbound.start() + assert.equal(await inbound.handleEvent(event({ type: 'image' })), 'ignored') + assert.equal(await inbound.handleEvent(event({ content: 'not json' })), 'ignored') + assert.equal(await inbound.handleEvent({ header: { event_id: 'e9' } }), 'ignored') + assert.equal(turns.length, 0) + assert.match(logs.join('\n'), /no readable text message/) +}) + +test('a text message drops the mention placeholders and names the chat after the conversation (#1266)', async () => { + const { inbound, turns } = setup({ nameOf: async () => ' Someone ' }) + await inbound.start() + await inbound.handleEvent(event({ text: '@_user_1 ping the desk' })) + assert.equal(turns[0].text, 'ping the desk') + assert.equal(turns[0].name, 'Someone') + const unnamed = setup({ nameOf: async () => { throw new Error('no profile read') } }) + await unnamed.inbound.start() + await unnamed.inbound.handleEvent(event({ chatId: 'oc_named_later' })) + assert.equal(unnamed.turns[0].name, 'feishu:oc_named_later') +}) + +test('readEvent reads only the envelope phase 2 means to accept (#1266)', () => { + assert.deepEqual(readEvent(event({ id: 'e5', chatId: 'oc_5', text: 'a line' })), { id: 'e5', openId: OWNER, chatId: 'oc_5', text: 'a line' }) + assert.equal(readEvent({}), null) + assert.equal(readEvent(event({ content: '{}' })), null) + assert.equal(readEvent(event({ text: ' ' })), null) + assert.equal(readEvent({ header: { event_id: 'e6' }, event: { sender: { sender_id: { open_id: OWNER } }, message: { chat_id: 'oc', message_type: 'text', content: '{"text":"x"}' } } }) !== null, true) +}) + +test('the app pair is one credential: either half missing dials nothing (#1266)', () => { + const secrets = { [APP_ID_SECRET]: 'cli_app_1', [APP_SECRET_SECRET]: 'app-secret-value' } + const read = name => secrets[name] + assert.deepEqual(appCredentials(read), { appId: 'cli_app_1', appSecret: 'app-secret-value' }) + assert.equal(appCredentials(name => (name === APP_ID_SECRET ? undefined : secrets[name])), undefined) + assert.equal(appCredentials(name => (name === APP_SECRET_SECRET ? ' ' : secrets[name])), undefined) + assert.equal(appCredentials(undefined), undefined) +}) + +test('the view never carries either half of the app pair (#1266)', async () => { + const secrets = { [APP_ID_SECRET]: 'cli_app_1', [APP_SECRET_SECRET]: 'app-secret-value' } + const seen = [] + const transport = { + connect: async () => { throw new Error(`handshake refused for ${secrets[APP_ID_SECRET]} / ${secrets[APP_SECRET_SECRET]}`) }, + } + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport, + deliver: async message => { seen.push(message) }, + redact: text => String(text).split(secrets[APP_SECRET_SECRET]).join('[redacted]').split(secrets[APP_ID_SECRET]).join('[redacted]'), + }) + await inbound.start() + const view = JSON.stringify(inbound.view()) + assert.match(view, /handshake refused/) + assert.doesNotMatch(view, /app-secret-value/) + assert.doesNotMatch(view, /cli_app_1/) + assert.equal(seen.length, 0) +}) + +test('with no socket adapter installed it says so instead of pretending (#1266)', async () => { + const inbound = createInbound({ enabled: true, ownerOpenId: OWNER, deliver: async () => {} }) + assert.equal(await inbound.start(), 'error') + assert.deepEqual(inbound.view().error, 'no long-connection transport is installed') + assert.equal(inbound.view().connected, 0) +}) + +test('an adapter installed after the first start is the one the next start dials (#1266)', async () => { + // The policy is made before the adapter exists — the shape a module that installs its socket later + // has. A missing adapter must not be remembered as the answer. + const transport = { current: undefined } + const dials = [] + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: () => transport.current, + deliver: async () => {}, + }) + assert.equal(await inbound.start(), 'error') + assert.equal(inbound.view().error, 'no long-connection transport is installed') + transport.current = { connect: async onEvent => { dials.push(onEvent); return { close: async () => {} } } } + assert.equal(await inbound.start(), 'ready') + assert.equal(dials.length, 1, 'the adapter that arrived late is the one that dialled') + assert.equal(inbound.view().connected, 1) +}) + +test('a stop while the socket dials hangs up the one that arrives and never reports ready (#1266)', async () => { + let release + const dialling = new Promise(resolve => { release = resolve }) + const closed = [] + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: { + connect: async () => { await dialling; return { close: async () => { closed.push(1) } } }, + }, + deliver: async () => {}, + }) + const started = inbound.start() + await inbound.stop() + release() + assert.equal(await started, 'stopped', 'the dial that outlived stop is not adopted') + assert.deepEqual(closed, [1], 'the socket that arrived after stop is hung up, not leaked') + assert.equal(inbound.view().state, 'connecting', 'the row does not flip back to ready behind the owner') + assert.equal(inbound.view().connected, 0) +}) + +// #1266 INC (Reviewer): the ring used to be written *before* `deliver`, so a delivery that threw +// left the id in it and every replay read `duplicate` — a lost message the owner could never get +// back. The id now enters the ring only once the message has been handed to its chat. +test('a message whose delivery throws is handed over again, not remembered as handled (#1266)', async () => { + const turns = [] + let refuse = true + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: { connect: async () => ({ close: async () => {} }) }, + deliver: async message => { + if (refuse) { + refuse = false + throw new Error('no chat seam is installed for a Feishu message') + } + turns.push(message) + }, + }) + await inbound.start() + await assert.rejects(inbound.handleEvent(event()), /no chat seam is installed/) + assert.equal(await inbound.handleEvent(event()), 'delivered', 'the same event_id is handled again') + assert.equal(turns.length, 1) + assert.equal(inbound.view().duplicates, 0, 'a failed delivery is not a duplicate') + assert.equal(inbound.seen(), 1, 'the ring holds the id once, after the delivery succeeded') +}) + +test('two copies of one event arriving together still start one turn (#1266)', async () => { + const turns = [] + let release + const handed = new Promise(resolve => { release = resolve }) + const inbound = createInbound({ + enabled: true, + ownerOpenId: OWNER, + transport: { connect: async () => ({ close: async () => {} }) }, + deliver: async message => { await handed; turns.push(message) }, + }) + await inbound.start() + const first = inbound.handleEvent(event()) + const second = inbound.handleEvent(event()) + release() + const results = await Promise.all([first, second]) + assert.deepEqual(results.sort(), ['delivered', 'duplicate']) + assert.equal(turns.length, 1) +}) diff --git a/patches/ds-bot/files/test/feishu-sdk.test.js b/patches/ds-bot/files/test/feishu-sdk.test.js new file mode 100644 index 00000000..4faa21c8 --- /dev/null +++ b/patches/ds-bot/files/test/feishu-sdk.test.js @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { readEvent } from '../src/host/feishu-inbound.js' +import { SDK_PACKAGE, createFeishuTransport } from '../src/host/feishu-sdk.js' + +// #1266: the one adapter that needs Feishu's own SDK. What is asserted here is the seam, not the +// network: the package is imported only when a socket is dialled, the SDK's flattened v2 event is +// handed to the policy as Feishu's own envelope, and a socket that fails or never opens ends the +// start with a line the row can show. + +const APP = { appId: 'cli_0123456789abcdef', appSecret: 'app-secret-value' } + +// A stand-in for the package: it records what the adapter asked it to do. +function fakeSdk({ open = true } = {}) { + const made = [] + class EventDispatcher { + register(handles) { this.handles = handles; return this } + } + class WSClient { + constructor(params) { this.params = params; this.closed = []; made.push(this) } + start({ eventDispatcher }) { + this.handles = eventDispatcher.handles + if (open) this.params.onReady?.() + return Promise.resolve() + } + close(params) { this.closed.push(params ?? {}) } + } + return { made, sdk: { WSClient, EventDispatcher } } +} + +const doesNotImport = () => { throw new Error('the package must not be imported here') } + +test('the SDK is imported at dial time, and its flattened event reaches the policy as Feishu sends it (#1266)', async () => { + const { made, sdk } = fakeSdk() + let imported = 0 + const seen = [] + const transport = createFeishuTransport({ + credentials: () => APP, + importSdk: async () => { imported += 1; return sdk }, + }) + assert.equal(imported, 0, 'nothing is imported before a socket is dialled') + const closer = await transport.connect(envelope => { seen.push(envelope) }) + assert.equal(imported, 1) + assert.equal(made[0].params.appId, APP.appId) + assert.equal(made[0].params.appSecret, APP.appSecret) + + // The SDK merges `header` and `event` into one object (`event_id` beside `message`); the adapter + // rebuilds the envelope `readEvent` reads, so the policy keeps one shape. + await made[0].handles['im.message.receive_v1']({ + event_id: 'e1', + sender: { sender_id: { open_id: 'ou_owner' } }, + message: { chat_id: 'oc_1', message_type: 'text', content: '{"text":"hello there"}' }, + }) + assert.deepEqual(readEvent(seen[0]), { id: 'e1', openId: 'ou_owner', chatId: 'oc_1', text: 'hello there' }) + + closer.close() + assert.deepEqual(made[0].closed, [{ force: true }]) + closer.close() + assert.equal(made[0].closed.length, 1, 'closing twice hangs up once') +}) + +test('a package that is not installed is one readable line, not a stack (#1266)', async () => { + const transport = createFeishuTransport({ + credentials: () => APP, + importSdk: async () => { throw new Error(`Cannot find package '${SDK_PACKAGE}' imported from core`) }, + }) + await assert.rejects(transport.connect(async () => {}), /needs @larksuiteoapi\/node-sdk installed: Cannot find package/) +}) + +test('with no app pair saved nothing is imported and nothing dials (#1266)', async () => { + const transport = createFeishuTransport({ credentials: () => undefined, importSdk: doesNotImport }) + await assert.rejects(transport.connect(async () => {}), /app id and app secret are needed/) +}) + +test('a socket that reports an error ends the start with that error (#1266)', async () => { + class WSClient { + constructor(params) { this.params = params } + start() { this.params.onError(new Error('handshake refused')) } + close() {} + } + class EventDispatcher { register(handles) { this.handles = handles; return this } } + const transport = createFeishuTransport({ credentials: () => APP, importSdk: async () => ({ WSClient, EventDispatcher }) }) + await assert.rejects(transport.connect(async () => {}), /handshake refused/) +}) + +test('a socket that never opens is given up at the deadline instead of hanging the start (#1266)', async () => { + const { made, sdk } = fakeSdk({ open: false }) + const transport = createFeishuTransport({ credentials: () => APP, importSdk: async () => sdk, readyTimeoutMs: 10 }) + await assert.rejects(transport.connect(async () => {}), /did not open/) + assert.equal(made[0].closed.length, 1, 'the half-open client is hung up') +}) diff --git a/patches/ds-bot/files/test/feishu.test.js b/patches/ds-bot/files/test/feishu.test.js index f0755e2a..149f5322 100644 --- a/patches/ds-bot/files/test/feishu.test.js +++ b/patches/ds-bot/files/test/feishu.test.js @@ -61,6 +61,9 @@ async function call(ctx, endpoint, payload) { } const row = (view, id = 'feishu') => (view?.connectors ?? view ?? []).find(entry => entry.id === id) +// #1266: the same row now carries the inbound state (off, no socket, app pair not saved). It is +// spelled out here instead of spread, so a value leaking into the row cannot pass unnoticed. +const INBOUND_OFF = { state: 'off', connected: 0, duplicates: 0, refused: 0, app: 'missing' } const said = (warns, needle) => warns.filter(args => args.some(value => String(value).includes(needle))) // A fake Feishu that records each POST and answers from the script (the last answer repeats). @@ -78,7 +81,7 @@ test('#1263: Feishu is a sender row of its own, off until a webhook is pasted', const { ctx, home } = setup() t.after(async () => { await ctx.dispose(); cleanHome(home) }) const state = await call(ctx, 'state', {}) - assert.deepEqual(row(state.value), { id: 'feishu', name: 'Feishu', state: 'off', kind: 'sender', account: null }) + assert.deepEqual(row(state.value), { id: 'feishu', name: 'Feishu', state: 'off', kind: 'sender', account: null, inbound: INBOUND_OFF }) // A sender has no tools, so it is not a name a saved capability may take either way. assert.deepEqual(state.value.connectors.map(entry => entry.id), ['github', 'composio', 'feishu']) }) @@ -94,7 +97,7 @@ test('#1263: connecting posts one test message, turns ready, and keeps the webho const connected = await call(ctx, 'connector-connect', { id: 'feishu', token: ` ${webhook} ` }) assert.equal(connected.ok, true, JSON.stringify(connected)) - assert.deepEqual(row(connected.value), { id: 'feishu', name: 'Feishu', state: 'ready', kind: 'sender', account: null }) + assert.deepEqual(row(connected.value), { id: 'feishu', name: 'Feishu', state: 'ready', kind: 'sender', account: null, inbound: INBOUND_OFF }) assert.equal(feishu.posts.length, 1, 'one test POST on connect') assert.equal(feishu.posts[0].url, webhook) diff --git a/patches/ds-bot/files/test/remote-chat.test.js b/patches/ds-bot/files/test/remote-chat.test.js index 4f5902ed..39b95d85 100644 --- a/patches/ds-bot/files/test/remote-chat.test.js +++ b/patches/ds-bot/files/test/remote-chat.test.js @@ -2,6 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { remoteTurn, isRemoteChat, REMOTE_PROVIDER, install, notSentLine } from '../src/host/remote-chat.js'; import { isHomeLevelRemote, isListedRemoteChat, isShownRemoteChat } from '../src/shared/remote-label.js'; +import { feishuReply } from '../src/host/connectors.js'; const user = (content, source, id = 'm1') => ({ role: 'user', id, content, ...(source ? { source } : {}) }); @@ -282,3 +283,24 @@ test('a line Bot Social attests the friend typed themselves carries the person\' assert.equal(followups[0].source.form, 'remote-chat') assert.equal(followups[1].source.text, 'bot line') }) + +// #1266 INC (Reviewer): the whole exit of a Feishu answer, composed. A `feishu:` chat carries the +// feishu plugin, the plugin's sender is the one `connectors.js` registers, and a message typed into +// that chat leaves on the webhook — not as "Not sent: the plugin that reaches this Bot is not running." +test('a reply typed into a feishu chat leaves on the Feishu webhook (#1266)', async () => { + const state = { bots: { f1: { id: 'f1', name: '飞书 Owner', remote: { handle: 'feishu:oc_chat_1', plugin: 'feishu' } } } } + let adapter + const posts = [] + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: { llm: { registerAdapter: (_, a) => { adapter = a } } } } + install(rt) + rt.remoteChats.onSend('feishu', feishuReply({ + secretValue: name => (name === 'FEISHU_WEBHOOK' ? 'https://open.feishu.cn/open-apis/bot/v2/hook/abc' : undefined), + post: async (webhook, text) => { posts.push([webhook, text]); return { ok: true } }, + })) + let said = '' + for await (const event of adapter.stream({ model: 'feishu:oc_chat_1', messages: [{ role: 'user', id: 'm1', content: [{ type: 'text', text: 'the answer' }] }] })) { + if (event.type === 'text-delta') said += event.text + } + assert.equal(said, '', 'nothing is drawn for a send that left') + assert.deepEqual(posts, [['https://open.feishu.cn/open-apis/bot/v2/hook/abc', 'the answer']]) +})