diff --git a/dist/control-ui/index.html b/dist/control-ui/index.html new file mode 100644 index 000000000..a1f2bffae --- /dev/null +++ b/dist/control-ui/index.html @@ -0,0 +1,15 @@ + + + + + + Clawdbot Control + + + + + + + + + diff --git a/extensions/feishu/clawdbot.plugin.json b/extensions/feishu/clawdbot.plugin.json new file mode 100644 index 000000000..769969611 --- /dev/null +++ b/extensions/feishu/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "feishu", + "channels": [ + "feishu" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/feishu/index.ts b/extensions/feishu/index.ts new file mode 100644 index 000000000..6e7a7259d --- /dev/null +++ b/extensions/feishu/index.ts @@ -0,0 +1,16 @@ +import type { ClawdbotPluginApi } from "../../src/plugin-sdk/index.js"; +import { emptyPluginConfigSchema } from "../../src/plugin-sdk/index.js"; + +import { feishuPlugin } from "./src/channel.js"; + +const plugin = { + id: "feishu", + name: "Feishu", + description: "Feishu/Lark channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: ClawdbotPluginApi) { + api.registerChannel({ plugin: feishuPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/feishu/package-lock.json b/extensions/feishu/package-lock.json new file mode 100644 index 000000000..5ee1a42e2 --- /dev/null +++ b/extensions/feishu/package-lock.json @@ -0,0 +1,565 @@ +{ + "name": "@clawdbot/feishu", + "version": "2026.1.25", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@clawdbot/feishu", + "version": "2026.1.25", + "dependencies": { + "@larksuiteoapi/node-sdk": "^1.56.1" + } + }, + "node_modules/@larksuiteoapi/node-sdk": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.56.1.tgz", + "integrity": "sha512-/ixtyJnWOmcupKgDXz+6G6qTLMi3cNrR+LGOuq2PMwcJ6hhXTUJNyAF+ADY7ah9OoeDniGU/UJwMb2gqKdxwcA==", + "license": "MIT", + "dependencies": { + "axios": "0.27.2", + "lodash.identity": "^3.0.0", + "lodash.merge": "^4.6.2", + "lodash.pickby": "^4.6.0", + "protobufjs": "^7.2.6", + "qs": "^6.13.0", + "ws": "^8.16.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.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "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/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "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.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.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": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.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/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.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "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/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "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.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/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.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "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/extensions/feishu/package.json b/extensions/feishu/package.json new file mode 100644 index 000000000..c086b2adb --- /dev/null +++ b/extensions/feishu/package.json @@ -0,0 +1,14 @@ +{ + "name": "@clawdbot/feishu", + "version": "2026.1.25", + "type": "module", + "description": "Clawdbot Feishu/Lark channel plugin", + "clawdbot": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "@larksuiteoapi/node-sdk": "^1.56.1" + } +} diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts new file mode 100644 index 000000000..9bcfff147 --- /dev/null +++ b/extensions/feishu/src/channel.ts @@ -0,0 +1,179 @@ +import { + DEFAULT_ACCOUNT_ID, + getChatChannelMeta, + type ChannelPlugin, +} from "../../../src/plugin-sdk/index.js"; + +// Import feishu functions directly from source +import { + listFeishuAccountIds, + resolveFeishuAccount, + resolveDefaultFeishuAccountId, + type ResolvedFeishuAccount, +} from "../../../src/feishu/accounts.js"; +import { sendMessageFeishu, type SendFeishuMessageParams } from "../../../src/feishu/send.js"; +import { monitorFeishuProvider, type FeishuMessageContext } from "../../../src/feishu/monitor.js"; +import { createFeishuClient } from "../../../src/feishu/client.js"; +import { dispatchFeishuMessage } from "../../../src/feishu/message-dispatch.js"; + +const meta = getChatChannelMeta("feishu"); + +/** + * Detect the appropriate receiveIdType from Feishu target ID prefix + * - ou_xxx → open_id (user) + * - oc_xxx → chat_id (group chat) + * - on_xxx → union_id (cross-app user id) + */ +function detectFeishuReceiveIdType( + target: string, +): SendFeishuMessageParams["receiveIdType"] { + const trimmed = target.trim().toLowerCase(); + if (trimmed.startsWith("ou_")) return "open_id"; + if (trimmed.startsWith("on_")) return "union_id"; + // Default to chat_id for oc_ prefix or unknown formats + return "chat_id"; +} + +export const feishuPlugin: ChannelPlugin = { + id: "feishu", + meta: { + ...meta, + aliases: ["lark"], + }, + capabilities: { + chatTypes: ["direct", "channel"], + reactions: true, + media: true, + }, + config: { + listAccountIds: (cfg) => listFeishuAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg), + isConfigured: (account) => account.credentials.source !== "none", + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.credentials.source !== "none", + }), + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + }), + buildAccountSnapshot: ({ account, runtime }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.credentials.source !== "none", + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + }), + }, + messaging: { + targetResolver: { + hint: "Use ou_xxx (open_id), oc_xxx (chat_id), or on_xxx (union_id)", + // Recognize Feishu ID patterns: ou_ (open_id), oc_ (chat_id), on_ (union_id) + looksLikeId: (raw: string) => { + const trimmed = raw.trim(); + return /^(ou_|oc_|on_)[a-z0-9]+$/i.test(trimmed); + }, + }, + }, + outbound: { + deliveryMode: "direct", + chunker: null, + textChunkLimit: 4000, + sendText: async ({ to, text, accountId, cfg }) => { + // Detect receiveIdType from target prefix + const receiveIdType = detectFeishuReceiveIdType(to); + const result = await sendMessageFeishu({ + to, + text, + accountId: accountId ?? undefined, + config: cfg, + receiveIdType, + }); + if (!result.success || !result.messageId) { + throw new Error(result.error ?? "Failed to send Feishu message"); + } + return { channel: "feishu" as const, messageId: result.messageId }; + }, + sendMedia: async ({ to, text, accountId, cfg }) => { + // Feishu media sending requires separate API - for now just send text + const receiveIdType = detectFeishuReceiveIdType(to); + const result = await sendMessageFeishu({ + to, + text, + accountId: accountId ?? undefined, + config: cfg, + receiveIdType, + }); + if (!result.success || !result.messageId) { + throw new Error(result.error ?? "Failed to send Feishu message"); + } + return { channel: "feishu" as const, messageId: result.messageId }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.log?.info(`[${account.accountId}] starting Feishu provider`); + + // Create client for startup message + const client = createFeishuClient(account.credentials, { + timeoutMs: (account.config.timeoutSeconds ?? 30) * 1000, + }); + + // Message handler that dispatches to the agent system + const onMessage = async (msgCtx: FeishuMessageContext) => { + await dispatchFeishuMessage({ + ctx: msgCtx, + cfg: ctx.cfg, + runtime: ctx.runtime, + account, + }); + }; + + // Start the monitor with message handler + const monitorPromise = monitorFeishuProvider({ + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + onMessage, + }); + + // Send startup message if configured + const startupChatId = account.config.startupChatId; + if (startupChatId) { + try { + const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }); + await client.sendTextMessage( + startupChatId, + `🚀 Clawdbot 飞书网关已启动 (${timestamp})`, + "chat_id", + ); + ctx.log?.info(`[${account.accountId}] sent startup message to ${startupChatId}`); + } catch (err) { + ctx.log?.warn(`[${account.accountId}] failed to send startup message: ${err}`); + } + } + + return monitorPromise; + }, + }, +}; diff --git a/package.json b/package.json index 04322f3af..c9880eb49 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,7 @@ "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.4", + "@larksuiteoapi/node-sdk": "^1.56.1", "@line/bot-sdk": "^10.6.0", "@lydell/node-pty": "1.2.0-beta.3", "@mariozechner/pi-agent-core": "0.49.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c0f99928..c285c8c28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: '@homebridge/ciao': specifier: ^1.3.4 version: 1.3.4 + '@larksuiteoapi/node-sdk': + specifier: ^1.56.1 + version: 1.56.1 '@line/bot-sdk': specifier: ^10.6.0 version: 10.6.0 @@ -304,6 +307,12 @@ importers: extensions/discord: {} + extensions/feishu: + dependencies: + '@larksuiteoapi/node-sdk': + specifier: ^1.56.1 + version: 1.56.1 + extensions/google-antigravity-auth: {} extensions/google-gemini-cli-auth: {} @@ -1327,6 +1336,9 @@ packages: peerDependencies: apache-arrow: '>=15.0.0 <=18.1.0' + '@larksuiteoapi/node-sdk@1.56.1': + resolution: {integrity: sha512-/ixtyJnWOmcupKgDXz+6G6qTLMi3cNrR+LGOuq2PMwcJ6hhXTUJNyAF+ADY7ah9OoeDniGU/UJwMb2gqKdxwcA==} + '@line/bot-sdk@10.6.0': resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==} engines: {node: '>=20'} @@ -3071,6 +3083,9 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + axios@0.27.2: + resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} + axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} @@ -4165,6 +4180,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.identity@3.0.0: + resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -4183,9 +4201,15 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.pickby@4.6.0: + resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -6882,6 +6906,20 @@ snapshots: '@lancedb/lancedb-win32-arm64-msvc': 0.23.0 '@lancedb/lancedb-win32-x64-msvc': 0.23.0 + '@larksuiteoapi/node-sdk@1.56.1': + dependencies: + axios: 0.27.2 + lodash.identity: 3.0.0 + lodash.merge: 4.6.2 + lodash.pickby: 4.6.0 + protobufjs: 7.5.4 + qs: 6.14.1 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + '@line/bot-sdk@10.6.0': dependencies: '@types/node': 24.10.9 @@ -8929,6 +8967,13 @@ snapshots: aws4@1.13.2: {} + axios@0.27.2: + dependencies: + follow-redirects: 1.15.11(debug@4.4.3) + form-data: 4.0.5 + transitivePeerDependencies: + - debug + axios@1.13.2(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) @@ -10251,6 +10296,8 @@ snapshots: lodash.debounce@4.0.8: optional: true + lodash.identity@3.0.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -10263,8 +10310,12 @@ snapshots: lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.pickby@4.6.0: {} + lodash@4.17.23: {} log-symbols@6.0.0: diff --git a/src/agents/llm-proxy.ts b/src/agents/llm-proxy.ts new file mode 100644 index 000000000..16e62c6be --- /dev/null +++ b/src/agents/llm-proxy.ts @@ -0,0 +1,62 @@ +/** + * LLM API proxy support. + * + * Uses undici's ProxyAgent to route all fetch() calls through a configured proxy. + * This is needed because pi-ai uses globalThis.fetch internally and doesn't + * accept a custom fetch function. + */ +import { ProxyAgent, setGlobalDispatcher, getGlobalDispatcher, type Dispatcher } from "undici"; + +import type { ClawdbotConfig } from "../config/config.js"; + +let originalDispatcher: Dispatcher | null = null; +let currentProxyUrl: string | null = null; + +/** + * Configure the global fetch dispatcher to use an HTTP proxy for all requests. + * Call this before making LLM API calls when models.proxy is configured. + * + * Safe to call multiple times - only reconfigures if the proxy URL changes. + */ +export function setupLlmProxy(cfg: ClawdbotConfig | undefined): void { + const proxyUrl = cfg?.models?.proxy?.trim(); + + // No proxy configured - restore original dispatcher if we changed it + if (!proxyUrl) { + if (originalDispatcher) { + setGlobalDispatcher(originalDispatcher); + originalDispatcher = null; + currentProxyUrl = null; + } + return; + } + + // Same proxy already configured - no action needed + if (proxyUrl === currentProxyUrl) { + return; + } + + // Save original dispatcher on first proxy setup + if (!originalDispatcher) { + originalDispatcher = getGlobalDispatcher(); + } + + // Set up new proxy agent + const proxyAgent = new ProxyAgent(proxyUrl); + setGlobalDispatcher(proxyAgent); + currentProxyUrl = proxyUrl; +} + +/** + * Check if an LLM proxy is currently configured and active. + */ +export function isLlmProxyActive(): boolean { + return currentProxyUrl !== null; +} + +/** + * Get the currently configured proxy URL, if any. + */ +export function getLlmProxyUrl(): string | null { + return currentProxyUrl; +} diff --git a/src/agents/moltbot-tools.ts b/src/agents/moltbot-tools.ts index c10a55190..5e0d74741 100644 --- a/src/agents/moltbot-tools.ts +++ b/src/agents/moltbot-tools.ts @@ -18,6 +18,7 @@ import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js"; import { createTtsTool } from "./tools/tts-tool.js"; +import { createQverisTools } from "./tools/qveris-tools.js"; export function createMoltbotTools(options?: { sandboxBrowserBridgeUrl?: string; @@ -70,6 +71,11 @@ export function createMoltbotTools(options?: { config: options?.config, sandboxed: options?.sandboxed, }); + const qverisTools = createQverisTools({ + config: options?.config, + sandboxed: options?.sandboxed, + agentSessionKey: options?.agentSessionKey, + }); const tools: AnyAgentTool[] = [ createBrowserTool({ sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl, @@ -137,6 +143,7 @@ export function createMoltbotTools(options?: { ...(webSearchTool ? [webSearchTool] : []), ...(webFetchTool ? [webFetchTool] : []), ...(imageTool ? [imageTool] : []), + ...qverisTools, ]; const pluginTools = resolvePluginTools({ diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index dc68561c2..586e18c3f 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -24,6 +24,7 @@ import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { resolveUserPath } from "../../utils.js"; import { resolveMoltbotAgentDir } from "../agent-paths.js"; import { resolveSessionAgentIds } from "../agent-scope.js"; +import { setupLlmProxy } from "../llm-proxy.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js"; import { resolveMoltbotDocsPath } from "../docs-path.js"; import type { ExecElevatedDefaults } from "../bash-tools.js"; @@ -109,6 +110,9 @@ export type CompactEmbeddedPiSessionParams = { export async function compactEmbeddedPiSessionDirect( params: CompactEmbeddedPiSessionParams, ): Promise { + // Set up LLM proxy if configured (must be done before any fetch calls) + setupLlmProxy(params.config); + const resolvedWorkspace = resolveUserPath(params.workspaceDir); const prevCwd = process.cwd(); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 46a53bd8f..a5fe45fd7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -22,6 +22,7 @@ import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { resolveUserPath } from "../../../utils.js"; import { createCacheTrace } from "../../cache-trace.js"; import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js"; +import { setupLlmProxy } from "../../llm-proxy.js"; import { resolveMoltbotAgentDir } from "../../agent-paths.js"; import { resolveSessionAgentIds } from "../../agent-scope.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js"; @@ -133,6 +134,9 @@ export function injectHistoryImagesIntoMessages( export async function runEmbeddedAttempt( params: EmbeddedRunAttemptParams, ): Promise { + // Set up LLM proxy if configured (must be done before any fetch calls) + setupLlmProxy(params.config); + const resolvedWorkspace = resolveUserPath(params.workspaceDir); const prevCwd = process.cwd(); const runAbortController = new AbortController(); diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index 514b5338a..73b94cfed 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -14,6 +14,8 @@ export const TOOL_GROUPS: Record = { // NOTE: Keep canonical (lowercase) tool names here. "group:memory": ["memory_search", "memory_get"], "group:web": ["web_search", "web_fetch"], + // QVeris dynamic tool discovery and execution + "group:qveris": ["qveris_search", "qveris_execute"], // Basic workspace/file tools "group:fs": ["read", "write", "edit", "apply_patch"], // Host/runtime execution tools @@ -53,6 +55,8 @@ export const TOOL_GROUPS: Record = { "web_search", "web_fetch", "image", + "qveris_search", + "qveris_execute", ], }; @@ -61,7 +65,7 @@ const TOOL_PROFILES: Record = { allow: ["session_status"], }, coding: { - allow: ["group:fs", "group:runtime", "group:sessions", "group:memory", "image"], + allow: ["group:fs", "group:runtime", "group:sessions", "group:memory", "image", "group:qveris"], }, messaging: { allow: [ diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index ff3ca1b2c..8d11668dc 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -13,6 +13,7 @@ import { Type } from "@sinclair/typebox"; import type { MoltbotConfig } from "../../config/config.js"; import { resolveUserPath } from "../../utils.js"; +import { setupLlmProxy } from "../llm-proxy.js"; import { loadWebMedia } from "../../web/media.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "../auth-profiles.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; @@ -219,6 +220,9 @@ async function runImagePrompt(params: { model: string; attempts: Array<{ provider: string; model: string; error: string }>; }> { + // Set up LLM proxy if configured (must be done before any fetch calls) + setupLlmProxy(params.cfg); + const effectiveCfg: MoltbotConfig | undefined = params.cfg ? { ...params.cfg, diff --git a/src/agents/tools/qveris-tools.ts b/src/agents/tools/qveris-tools.ts new file mode 100644 index 000000000..d5b5ad070 --- /dev/null +++ b/src/agents/tools/qveris-tools.ts @@ -0,0 +1,516 @@ +import { Type } from "@sinclair/typebox"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import type { AnyAgentTool } from "./common.js"; +import { jsonResult, readNumberParam, readStringParam } from "./common.js"; + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_QVERIS_BASE_URL = "https://qveris.ai/api/v1"; +const DEFAULT_TIMEOUT_SECONDS = 60; +const DEFAULT_MAX_RESPONSE_SIZE = 20480; +const DEFAULT_SEARCH_LIMIT = 10; +const DEFAULT_PRE_SEARCH_BYTE_THRESHOLD = 100; + +// ============================================================================ +// Types +// ============================================================================ + +type QverisConfig = NonNullable["qveris"]; + +/** Search result parameter from QVeris API */ +interface QverisSearchResultParam { + name: string; + type: string; + required: boolean; + description?: { + en?: string; + [key: string]: string | undefined; + }; +} + +/** Example format from QVeris search API */ +interface QverisSearchResultExamples { + sample_parameters?: Record; +} + +/** Search result tool from QVeris API */ +interface QverisSearchResultTool { + tool_id: string; + name: string; + description: string; + params?: QverisSearchResultParam[]; + provider_description?: string; + stats?: { + avg_execution_time_ms?: number; + success_rate?: number; + }; + examples?: QverisSearchResultExamples; +} + +/** QVeris search API response */ +interface QverisSearchResponse { + query: string; + total: number; + results: QverisSearchResultTool[]; + search_id: string; + elapsed_time_ms?: number; +} + +/** QVeris tool execution response */ +interface QverisExecutionResponse { + execution_id: string; + result: { + data: unknown; + }; + success: boolean; + error_message: string | null; + elapsed_time_ms: number; + cost?: number; + credits_used?: number; +} + +/** Mapping from sanitized function name to original tool info */ +interface QverisToolMapping { + functionName: string; + toolId: string; + searchId: string; +} + +// ============================================================================ +// Config Resolution +// ============================================================================ + +function resolveQverisConfig(cfg?: ClawdbotConfig): QverisConfig { + return cfg?.tools?.qveris; +} + +function resolveQverisEnabled(params: { config?: QverisConfig; sandboxed?: boolean }): boolean { + if (typeof params.config?.enabled === "boolean") return params.config.enabled; + // Enabled by default if API key is present + return Boolean(resolveQverisApiKey(params.config)); +} + +function resolveQverisApiKey(config?: QverisConfig): string | undefined { + const fromConfig = + config && "apiKey" in config && typeof config.apiKey === "string" ? config.apiKey.trim() : ""; + const fromEnv = (process.env.QVERIS_API_KEY ?? "").trim(); + return fromConfig || fromEnv || undefined; +} + +function resolveQverisBaseUrl(config?: QverisConfig): string { + return config?.baseUrl?.trim() || DEFAULT_QVERIS_BASE_URL; +} + +function resolveTimeoutSeconds(config?: QverisConfig): number { + return config?.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS; +} + +function resolveMaxResponseSize(config?: QverisConfig): number { + return config?.maxResponseSize ?? DEFAULT_MAX_RESPONSE_SIZE; +} + +function resolveSearchLimit(config?: QverisConfig): number { + return config?.searchLimit ?? DEFAULT_SEARCH_LIMIT; +} + +function resolvePreSearchEnabled(config?: QverisConfig): boolean { + return config?.preSearchEnabled !== false; +} + +function resolvePreSearchByteThreshold(config?: QverisConfig): number { + return config?.preSearchByteThreshold ?? DEFAULT_PRE_SEARCH_BYTE_THRESHOLD; +} + +// ============================================================================ +// API Client +// ============================================================================ + +async function qverisSearch(params: { + query: string; + sessionId: string; + limit: number; + apiKey: string; + baseUrl: string; + timeoutSeconds: number; +}): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), params.timeoutSeconds * 1000); + + try { + const res = await fetch(`${params.baseUrl}/search`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify({ + query: params.query, + limit: params.limit, + session_id: params.sessionId, + }), + signal: controller.signal, + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`QVeris search failed (${res.status}): ${detail || res.statusText}`); + } + + return (await res.json()) as QverisSearchResponse; + } finally { + clearTimeout(timeoutId); + } +} + +async function qverisExecute(params: { + toolId: string; + searchId: string; + sessionId: string; + parameters: Record; + maxResponseSize: number; + apiKey: string; + baseUrl: string; + timeoutSeconds: number; +}): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), params.timeoutSeconds * 1000); + + try { + const res = await fetch( + `${params.baseUrl}/tools/execute?tool_id=${encodeURIComponent(params.toolId)}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify({ + parameters: params.parameters, + max_response_size: params.maxResponseSize, + search_id: params.searchId, + session_id: params.sessionId, + }), + signal: controller.signal, + }, + ); + + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`QVeris execute failed (${res.status}): ${detail || res.statusText}`); + } + + return (await res.json()) as QverisExecutionResponse; + } finally { + clearTimeout(timeoutId); + } +} + +// ============================================================================ +// Tool Schemas +// ============================================================================ + +const QverisSearchSchema = Type.Object({ + query: Type.String({ + description: + "Search query describing the capability of the tool you need. Describe what you want to accomplish, not specific params to pass. Example: 'weather forecast API', 'send email', 'stock prices'.", + }), + limit: Type.Optional( + Type.Number({ + description: "Maximum number of results to return (1-100). Default: 10.", + minimum: 1, + maximum: 100, + }), + ), +}); + +const QverisExecuteSchema = Type.Object({ + tool_id: Type.String({ + description: "The ID of the tool to execute. Must come from a previous qveris_search call.", + }), + search_id: Type.String({ + description: + "The search_id from the qveris_search response that returned this tool. Required for linking execution to the original search.", + }), + params_to_tool: Type.String({ + description: + 'A JSON stringified dictionary of parameters to pass to the tool. Keys are param names and values can be of any type. Example: \'{"city": "London", "units": "metric"}\'.', + }), + max_response_size: Type.Optional( + Type.Number({ + description: + "Maximum size of response data in bytes. If tool generates data longer than this, it will be truncated. Default: 20480 (20KB).", + }), + ), +}); + +// ============================================================================ +// Pre-Search Handler +// ============================================================================ + +/** + * PreSearchToolHandler manages the pre-search flow: + * 1. Determines if pre-search should be performed based on first query length + * 2. Performs pre-search and stores results + * 3. Provides tool mappings for execution + */ +class PreSearchToolHandler { + private searchId: string = ""; + private toolMappings = new Map(); + private preSearchedTools: QverisSearchResultTool[] = []; + private preSearchPerformed: boolean = false; + private preSearchEnabled: boolean = false; + private byteThreshold: number; + + constructor(byteThreshold: number = DEFAULT_PRE_SEARCH_BYTE_THRESHOLD) { + this.byteThreshold = byteThreshold; + } + + /** + * Check if pre-search should be performed + * Conditions: + * 1. First query length in bytes <= threshold + */ + shouldPreSearch(firstQuery: string): boolean { + const byteLength = new TextEncoder().encode(firstQuery).length; + return byteLength <= this.byteThreshold; + } + + /** + * Perform pre-search using the query + */ + async preSearch(params: { + query: string; + sessionId: string; + apiKey: string; + baseUrl: string; + timeoutSeconds: number; + searchLimit: number; + }): Promise { + if (this.preSearchPerformed) { + return null; + } + + try { + const result = await qverisSearch({ + query: params.query, + sessionId: params.sessionId, + limit: params.searchLimit, + apiKey: params.apiKey, + baseUrl: params.baseUrl, + timeoutSeconds: params.timeoutSeconds, + }); + + if (result.results && result.results.length > 0) { + this.searchId = result.search_id; + this.preSearchedTools = result.results; + + // Store mappings for execution + for (const tool of result.results) { + const functionName = sanitizeToolName(tool.tool_id); + this.toolMappings.set(functionName, { + functionName, + toolId: tool.tool_id, + searchId: result.search_id, + }); + } + + this.preSearchEnabled = true; + } + + this.preSearchPerformed = true; + return result; + } catch (error) { + this.preSearchPerformed = true; + return null; + } + } + + isPreSearchEnabled(): boolean { + return this.preSearchEnabled && this.preSearchedTools.length > 0; + } + + getPreSearchedTools(): QverisSearchResultTool[] { + return this.preSearchedTools; + } + + getSearchId(): string { + return this.searchId; + } + + isPreSearchedTool(toolName: string): boolean { + return this.toolMappings.has(toolName); + } + + getToolMapping(functionName: string): QverisToolMapping | undefined { + return this.toolMappings.get(functionName); + } +} + +/** + * Sanitize tool ID to create a valid function name + */ +function sanitizeToolName(toolId: string): string { + if (!toolId || typeof toolId !== "string") { + toolId = "unknown_tool"; + } + + // Replace invalid characters with underscore + let sanitized = toolId.replace(/[^a-zA-Z0-9_-]/g, "_"); + + // Remove leading hyphens and numbers + sanitized = sanitized.replace(/^[-0-9]+/, ""); + + // If empty after sanitization, use a default name + if (!sanitized || sanitized === "_") { + sanitized = "tool_" + Date.now().toString(36); + } + + // Ensure starts with letter or underscore + if (sanitized.startsWith("-")) { + sanitized = "_" + sanitized.substring(1); + } + + // Truncate to 64 characters + if (sanitized.length > 64) { + sanitized = sanitized.substring(0, 64); + } + + return sanitized; +} + +// ============================================================================ +// Tool Creation +// ============================================================================ + +export function createQverisTools(options?: { + config?: ClawdbotConfig; + sandboxed?: boolean; + agentSessionKey?: string; +}): AnyAgentTool[] { + const config = resolveQverisConfig(options?.config); + + if (!resolveQverisEnabled({ config, sandboxed: options?.sandboxed })) { + return []; + } + + const apiKey = resolveQverisApiKey(config); + if (!apiKey) { + // Return empty array if no API key - tools won't be available + return []; + } + + const baseUrl = resolveQverisBaseUrl(config); + const timeoutSeconds = resolveTimeoutSeconds(config); + const maxResponseSize = resolveMaxResponseSize(config); + const searchLimit = resolveSearchLimit(config); + + // Generate a session ID tied to clawdbot session key + const sessionId = options?.agentSessionKey ?? `clawdbot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const searchTool: AnyAgentTool = { + label: "QVeris Search", + name: "qveris_search", + description: + "Search for available third-party tools using natural language. Returns relevant tools that can help accomplish tasks. Use this to discover tools before executing them with qveris_execute.", + parameters: QverisSearchSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const query = readStringParam(params, "query", { required: true }); + const limit = readNumberParam(params, "limit", { integer: true }) ?? searchLimit; + + const result = await qverisSearch({ + query, + sessionId, + limit: Math.min(Math.max(1, limit), 100), + apiKey, + baseUrl, + timeoutSeconds, + }); + + // Return simplified result for the model + return jsonResult({ + query: result.query, + total: result.total, + search_id: result.search_id, + elapsed_time_ms: result.elapsed_time_ms, + results: result.results.map((tool) => ({ + tool_id: tool.tool_id, + name: tool.name, + description: tool.description, + params: tool.params?.map((p) => ({ + name: p.name, + type: p.type, + required: p.required, + description: p.description?.en ?? Object.values(p.description ?? {})[0], + })), + examples: tool.examples?.sample_parameters + ? { sample_parameters: tool.examples.sample_parameters } + : undefined, + stats: tool.stats, + })), + }); + }, + }; + + const executeTool: AnyAgentTool = { + label: "QVeris Execute", + name: "qveris_execute", + description: + "Execute a specific third-party tool with provided parameters. The tool_id and search_id must come from a previous qveris_search call. Pass parameters to the tool through params_to_tool as a JSON string.", + parameters: QverisExecuteSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const toolId = readStringParam(params, "tool_id", { required: true }); + const searchId = readStringParam(params, "search_id", { required: true }); + const paramsToToolRaw = readStringParam(params, "params_to_tool", { required: true }); + const maxSize = + readNumberParam(params, "max_response_size", { integer: true }) ?? maxResponseSize; + + // Parse params_to_tool JSON + let toolParams: Record; + try { + toolParams = JSON.parse(paramsToToolRaw) as Record; + } catch (parseError) { + throw new Error( + `Invalid JSON in params_to_tool: ${parseError instanceof Error ? parseError.message : "Unknown parse error"}`, + ); + } + + const result = await qverisExecute({ + toolId, + searchId, + sessionId, + parameters: toolParams, + maxResponseSize: maxSize, + apiKey, + baseUrl, + timeoutSeconds, + }); + + return jsonResult({ + execution_id: result.execution_id, + success: result.success, + elapsed_time_ms: result.elapsed_time_ms, + result: result.result?.data, + error_message: result.error_message, + cost: result.cost ?? result.credits_used, + }); + }, + }; + + return [searchTool, executeTool]; +} + +/** + * Check if QVeris tools are enabled/available + */ +export function isQverisEnabled(options?: { + config?: ClawdbotConfig; + sandboxed?: boolean; +}): boolean { + const config = resolveQverisConfig(options?.config); + return resolveQverisEnabled({ config, sandboxed: options?.sandboxed }) && Boolean(resolveQverisApiKey(config)); +} diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 600499091..d7abe601f 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -1,5 +1,6 @@ import type { MoltbotConfig } from "../config/config.js"; import { resolveDiscordAccount } from "../discord/accounts.js"; +import { resolveFeishuAccount } from "../feishu/accounts.js"; import { resolveIMessageAccount } from "../imessage/accounts.js"; import { resolveSignalAccount } from "../signal/accounts.js"; import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js"; @@ -213,6 +214,38 @@ const DOCKS: Record = { }), }, }, + feishu: { + id: "feishu", + capabilities: { + chatTypes: ["direct", "group"], + reactions: true, + media: true, + blockStreaming: true, + }, + outbound: { textChunkLimit: 4000 }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => + (resolveFeishuAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => + String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^feishu:/i, "").toLowerCase()), + }, + threading: { + resolveReplyToMode: ({ cfg }) => cfg.channels?.feishu?.replyToMode ?? "first", + buildToolContext: ({ context, hasRepliedRef }) => { + const threadId = context.MessageThreadId ?? context.ReplyToId; + return { + currentChannelId: context.To?.trim() || undefined, + currentThreadTs: threadId != null ? String(threadId) : undefined, + hasRepliedRef, + }; + }, + }, + }, googlechat: { id: "googlechat", capabilities: { diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 6afe1996c..adb3882aa 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -8,6 +8,7 @@ export const CHAT_CHANNEL_ORDER = [ "telegram", "whatsapp", "discord", + "feishu", "googlechat", "slack", "signal", @@ -58,6 +59,16 @@ const CHAT_CHANNEL_META: Record = { blurb: "very well supported right now.", systemImage: "bubble.left.and.bubble.right", }, + feishu: { + id: "feishu", + label: "Feishu", + selectionLabel: "Feishu (Lark Bot API)", + detailLabel: "Feishu Bot", + docsPath: "/channels/feishu", + docsLabel: "feishu", + blurb: "Feishu/Lark bot with webhook or WebSocket event subscription.", + systemImage: "message.badge.circle", + }, googlechat: { id: "googlechat", label: "Google Chat", @@ -104,6 +115,7 @@ export const CHAT_CHANNEL_ALIASES: Record = { imsg: "imessage", "google-chat": "googlechat", gchat: "googlechat", + lark: "feishu", }; const normalizeChannelKey = (raw?: string | null): string | undefined => { diff --git a/src/config/schema.ts b/src/config/schema.ts index b4ec8723b..41717c93b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -199,6 +199,14 @@ const FIELD_LABELS: Record = { "tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)", "tools.web.fetch.maxRedirects": "Web Fetch Max Redirects", "tools.web.fetch.userAgent": "Web Fetch User-Agent", + "tools.qveris.enabled": "Enable QVeris Tools", + "tools.qveris.apiKey": "QVeris API Key", + "tools.qveris.baseUrl": "QVeris API Base URL", + "tools.qveris.timeoutSeconds": "QVeris Timeout (sec)", + "tools.qveris.maxResponseSize": "QVeris Max Response Size", + "tools.qveris.searchLimit": "QVeris Search Limit", + "tools.qveris.preSearchEnabled": "QVeris Pre-Search", + "tools.qveris.preSearchByteThreshold": "QVeris Pre-Search Threshold", "gateway.controlUi.basePath": "Control UI Base Path", "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", @@ -462,6 +470,17 @@ const FIELD_HELP: Record = { "tools.web.fetch.firecrawl.maxAgeMs": "Firecrawl maxAge (ms) for cached results when supported by the API.", "tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.", + "tools.qveris.enabled": "Enable QVeris dynamic tool discovery (requires API key).", + "tools.qveris.apiKey": "QVeris API key (fallback: QVERIS_API_KEY env var).", + "tools.qveris.baseUrl": "QVeris API base URL (default: https://qveris.ai/api/v1).", + "tools.qveris.timeoutSeconds": "Timeout in seconds for QVeris requests (default: 60).", + "tools.qveris.maxResponseSize": + "Max response size in bytes for QVeris tool execution (default: 20480).", + "tools.qveris.searchLimit": "Default number of tools to return from search (default: 10).", + "tools.qveris.preSearchEnabled": + "Enable pre-search optimization for short queries (default: true).", + "tools.qveris.preSearchByteThreshold": + "Byte threshold for pre-search (queries under this limit trigger pre-search; default: 100).", "channels.slack.allowBots": "Allow bot-authored messages to trigger Slack replies (default: false).", "channels.slack.thread.historyScope": diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index 77f4f1bc3..8a638c1ad 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -1,4 +1,5 @@ import type { DiscordConfig } from "./types.discord.js"; +import type { FeishuConfig } from "./types.feishu.js"; import type { GoogleChatConfig } from "./types.googlechat.js"; import type { IMessageConfig } from "./types.imessage.js"; import type { MSTeamsConfig } from "./types.msteams.js"; @@ -28,6 +29,7 @@ export type ChannelsConfig = { whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; + feishu?: FeishuConfig; googlechat?: GoogleChatConfig; slack?: SlackConfig; signal?: SignalConfig; diff --git a/src/config/types.feishu.ts b/src/config/types.feishu.ts new file mode 100644 index 000000000..61a070924 --- /dev/null +++ b/src/config/types.feishu.ts @@ -0,0 +1,153 @@ +import type { + BlockStreamingChunkConfig, + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, + OutboundRetryConfig, + ReplyToMode, +} from "./types.base.js"; +import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; +import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; +import type { GroupToolPolicyConfig } from "./types.tools.js"; + +export type FeishuActionConfig = { + reactions?: boolean; + sendMessage?: boolean; + deleteMessage?: boolean; + editMessage?: boolean; +}; + +/** + * Event subscription mode for Feishu: + * - "webhook": Use HTTP webhook to receive events (requires public URL) + * - "websocket": Use WebSocket long connection (no public URL required) + */ +export type FeishuEventMode = "webhook" | "websocket"; + +export type FeishuAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start this Feishu account. Default: true. */ + enabled?: boolean; + + /** Feishu App ID (from Feishu Open Platform). */ + appId?: string; + /** Feishu App Secret (from Feishu Open Platform). */ + appSecret?: string; + /** Path to file containing App Secret (for secret managers). */ + appSecretFile?: string; + /** Verification token for webhook events. */ + verificationToken?: string; + /** Encrypt key for event decryption (optional). */ + encryptKey?: string; + + /** Chat ID to send a startup message to when gateway starts. */ + startupChatId?: string; + + /** + * Event subscription mode: + * - "webhook": HTTP webhook (requires public URL) + * - "websocket": WebSocket long connection (recommended, no public URL needed) + * Default: "websocket" + */ + eventMode?: FeishuEventMode; + + /** Webhook URL for receiving events (if eventMode is "webhook"). */ + webhookUrl?: string; + /** Webhook path for receiving events. Default: "/feishu-webhook". */ + webhookPath?: string; + /** Port for webhook server. */ + webhookPort?: number; + + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** Markdown formatting overrides. */ + markdown?: MarkdownConfig; + /** Override native command registration for Feishu. */ + commands?: ProviderCommandsConfig; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; + + /** + * Controls how Feishu direct chats (DMs) are handled: + * - "pairing" (default): unknown senders get a pairing code; owner must approve + * - "allowlist": only allow senders in allowFrom + * - "open": allow all inbound DMs + * - "disabled": ignore all inbound DMs + */ + dmPolicy?: DmPolicy; + + /** Control reply threading when reply tags are present (off|first|all). */ + replyToMode?: ReplyToMode; + + /** Per-group configuration. */ + groups?: Record; + + /** Allowlist for DM senders (user IDs or open_ids). */ + allowFrom?: string[]; + /** Optional allowlist for Feishu group senders. */ + groupAllowFrom?: string[]; + + /** + * Controls how group messages are handled: + * - "open": groups bypass allowFrom, only mention-gating applies + * - "disabled": block all group messages entirely + * - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; + + /** Max group messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; + + /** Disable block streaming for this account. */ + blockStreaming?: boolean; + /** Chunking config for draft streaming. */ + draftChunk?: BlockStreamingChunkConfig; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** Draft streaming mode (off|partial|block). Default: partial. */ + streamMode?: "off" | "partial" | "block"; + + /** Max media file size in MB. Default: 20. */ + mediaMaxMb?: number; + /** API timeout in seconds. Default: 30. */ + timeoutSeconds?: number; + /** Retry policy for outbound API calls. */ + retry?: OutboundRetryConfig; + + /** Per-action tool gating (default: true for all). */ + actions?: FeishuActionConfig; + + /** Heartbeat visibility settings for this channel. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; +}; + +export type FeishuGroupConfig = { + /** If true, require @mention to respond in this group. */ + requireMention?: boolean; + /** Optional tool policy overrides for this group. */ + tools?: GroupToolPolicyConfig; + /** If specified, only load these skills for this group. */ + skills?: string[]; + /** If false, disable the bot for this group. */ + enabled?: boolean; + /** Optional allowlist for group senders. */ + allowFrom?: string[]; + /** Optional system prompt snippet for this group. */ + systemPrompt?: string; +}; + +export type FeishuConfig = { + /** Optional per-account Feishu configuration (multi-account). */ + accounts?: Record; +} & FeishuAccountConfig; diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 11b6c64cb..39086f658 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -56,4 +56,6 @@ export type ModelsConfig = { mode?: "merge" | "replace"; providers?: Record; bedrockDiscovery?: BedrockDiscoveryConfig; + /** HTTP proxy URL for all LLM API calls (e.g., "http://user:pass@proxy:8080"). */ + proxy?: string; }; diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index bb1d45bf0..fe0a16dc5 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -445,4 +445,23 @@ export type ToolsConfig = { deny?: string[]; }; }; + /** QVeris dynamic tool discovery and execution. */ + qveris?: { + /** Enable QVeris tools (default: true when API key is present). */ + enabled?: boolean; + /** QVeris API key (optional; defaults to QVERIS_API_KEY env var). */ + apiKey?: string; + /** QVeris API base URL (default: https://qveris.ai/api/v1). */ + baseUrl?: string; + /** Timeout in seconds for QVeris requests (default: 60). */ + timeoutSeconds?: number; + /** Max response size in bytes (default: 20480). */ + maxResponseSize?: number; + /** Default search result limit (default: 10). */ + searchLimit?: number; + /** Enable pre-search optimization for short queries (default: true). */ + preSearchEnabled?: boolean; + /** Byte threshold for pre-search (default: 100). */ + preSearchByteThreshold?: number; + }; }; diff --git a/src/config/types.ts b/src/config/types.ts index 424ccaf26..701df0195 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -10,6 +10,7 @@ export * from "./types.channels.js"; export * from "./types.clawdbot.js"; export * from "./types.cron.js"; export * from "./types.discord.js"; +export * from "./types.feishu.js"; export * from "./types.googlechat.js"; export * from "./types.gateway.js"; export * from "./types.hooks.js"; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 7a63e307d..57dc569d2 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -533,6 +533,19 @@ export const ToolsSchema = z }) .strict() .optional(), + qveris: z + .object({ + enabled: z.boolean().optional(), + apiKey: z.string().optional(), + baseUrl: z.string().optional(), + timeoutSeconds: z.number().int().positive().optional(), + maxResponseSize: z.number().int().positive().optional(), + searchLimit: z.number().int().positive().optional(), + preSearchEnabled: z.boolean().optional(), + preSearchByteThreshold: z.number().int().positive().optional(), + }) + .strict() + .optional(), }) .strict() .superRefine((value, ctx) => { diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 4a8c80bcc..8765c177d 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -77,6 +77,7 @@ export const ModelsConfigSchema = z mode: z.union([z.literal("merge"), z.literal("replace")]).optional(), providers: z.record(z.string(), ModelProviderSchema).optional(), bedrockDiscovery: BedrockDiscoverySchema, + proxy: z.string().optional(), }) .strict() .optional(); diff --git a/src/feishu/accounts.ts b/src/feishu/accounts.ts new file mode 100644 index 000000000..514163b16 --- /dev/null +++ b/src/feishu/accounts.ts @@ -0,0 +1,115 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { FeishuAccountConfig } from "../config/types.feishu.js"; +import { isTruthyEnvValue } from "../infra/env.js"; +import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import { resolveFeishuCredentials, type FeishuCredentials } from "./token.js"; + +const debugAccounts = (...args: unknown[]) => { + if (isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_FEISHU_ACCOUNTS)) { + console.warn("[feishu:accounts]", ...args); + } +}; + +export type ResolvedFeishuAccount = { + accountId: string; + enabled: boolean; + name?: string; + credentials: FeishuCredentials; + config: FeishuAccountConfig; +}; + +function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { + const accounts = cfg.channels?.feishu?.accounts; + if (!accounts || typeof accounts !== "object") return []; + const ids = new Set(); + for (const key of Object.keys(accounts)) { + if (!key) continue; + ids.add(normalizeAccountId(key)); + } + return [...ids]; +} + +export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] { + const ids = Array.from( + new Set([...listConfiguredAccountIds(cfg), ...listBoundAccountIds(cfg, "feishu")]), + ); + debugAccounts("listFeishuAccountIds", ids); + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; + return ids.sort((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string { + const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "feishu"); + if (boundDefault) return boundDefault; + const ids = listFeishuAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): FeishuAccountConfig | undefined { + const accounts = cfg.channels?.feishu?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + const direct = accounts[accountId] as FeishuAccountConfig | undefined; + if (direct) return direct; + const normalized = normalizeAccountId(accountId); + const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized); + return matchKey ? (accounts[matchKey] as FeishuAccountConfig | undefined) : undefined; +} + +function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): FeishuAccountConfig { + const { accounts: _ignored, ...base } = (cfg.channels?.feishu ?? {}) as FeishuAccountConfig & { + accounts?: unknown; + }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +export function resolveFeishuAccount(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): ResolvedFeishuAccount { + const hasExplicitAccountId = Boolean(params.accountId?.trim()); + const baseEnabled = params.cfg.channels?.feishu?.enabled !== false; + + const resolve = (accountId: string) => { + const merged = mergeFeishuAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const credentials = resolveFeishuCredentials(params.cfg, { accountId }); + debugAccounts("resolve", { + accountId, + enabled, + credentialSource: credentials.source, + }); + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + credentials, + config: merged, + } satisfies ResolvedFeishuAccount; + }; + + const normalized = normalizeAccountId(params.accountId); + const primary = resolve(normalized); + if (hasExplicitAccountId) return primary; + if (primary.credentials.source !== "none") return primary; + + // If accountId is omitted, prefer a configured account credential over failing on + // the implicit "default" account. + const fallbackId = resolveDefaultFeishuAccountId(params.cfg); + if (fallbackId === primary.accountId) return primary; + const fallback = resolve(fallbackId); + if (fallback.credentials.source === "none") return primary; + return fallback; +} + +export function listEnabledFeishuAccounts(cfg: ClawdbotConfig): ResolvedFeishuAccount[] { + return listFeishuAccountIds(cfg) + .map((accountId) => resolveFeishuAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/src/feishu/bot.ts b/src/feishu/bot.ts new file mode 100644 index 000000000..3ebe2774d --- /dev/null +++ b/src/feishu/bot.ts @@ -0,0 +1,292 @@ +/** + * Feishu Bot Message Handler + * + * Processes incoming messages and generates responses using the agent system. + */ + +import type { ClawdbotConfig } from "../config/config.js"; +import { loadConfig } from "../config/config.js"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/reply/history.js"; +import { + resolveChannelGroupPolicy, + resolveChannelGroupRequireMention, +} from "../config/group-policy.js"; +import { danger, logVerbose } from "../globals.js"; +import { formatUncaughtError } from "../infra/errors.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveFeishuAccount, type ResolvedFeishuAccount } from "./accounts.js"; +import { createFeishuClient, type FeishuClient } from "./client.js"; +import type { FeishuMessageContext } from "./monitor.js"; + +export type FeishuBotOptions = { + accountId?: string; + runtime?: RuntimeEnv; + config?: ClawdbotConfig; + /** Whether to require @mention in group chats */ + requireMention?: boolean; + /** Allowlist for DM senders */ + allowFrom?: string[]; + /** Allowlist for group senders */ + groupAllowFrom?: string[]; +}; + +export type FeishuBotContext = { + bot: FeishuBot; + ctx: FeishuMessageContext; +}; + +/** + * Feishu Bot class for handling messages + */ +export class FeishuBot { + readonly account: ResolvedFeishuAccount; + readonly client: FeishuClient; + readonly cfg: ClawdbotConfig; + readonly runtime: RuntimeEnv; + + private readonly historyLimit: number; + private readonly groupHistories = new Map(); + private readonly opts: FeishuBotOptions; + private botOpenId?: string; + + constructor(opts: FeishuBotOptions) { + this.cfg = opts.config ?? loadConfig(); + this.runtime = opts.runtime ?? { + log: console.log, + error: console.error, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }; + this.opts = opts; + + this.account = resolveFeishuAccount({ + cfg: this.cfg, + accountId: opts.accountId, + }); + + if (this.account.credentials.source === "none") { + throw new Error(`Feishu credentials missing for account "${this.account.accountId}".`); + } + + this.client = createFeishuClient(this.account.credentials, { + timeoutMs: (this.account.config.timeoutSeconds ?? 30) * 1000, + }); + + this.historyLimit = Math.max( + 0, + this.account.config.historyLimit ?? + this.cfg.messages?.groupChat?.historyLimit ?? + DEFAULT_GROUP_HISTORY_LIMIT, + ); + } + + /** + * Initialize the bot (fetch bot info) + */ + async init(): Promise { + try { + const botInfo = await this.client.getBotInfo(); + this.botOpenId = botInfo.open_id; + this.runtime.log?.(`feishu: initialized as "${botInfo.app_name}" (${botInfo.open_id})`); + } catch (err) { + throw new Error(`Feishu bot init failed: ${formatUncaughtError(err)}`); + } + } + + /** + * Check if sender is allowed based on allowlist + */ + private isAllowed(ctx: FeishuMessageContext): boolean { + const isGroup = ctx.chatType === "group"; + const senderId = ctx.senderId; + + // Check group policy + if (isGroup) { + const groupPolicy = resolveChannelGroupPolicy({ + cfg: this.cfg, + channel: "feishu", + accountId: this.account.accountId, + groupId: ctx.chatId, + }); + + // Check if the group is allowed based on allowlist + if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) { + logVerbose(`feishu: group ${ctx.chatId} not in allowlist`); + return false; + } + + // Check groupAllowFrom for sender filtering + const groupAllowFrom = this.opts.groupAllowFrom ?? this.account.config.groupAllowFrom ?? []; + if (groupAllowFrom.length > 0 && !groupAllowFrom.includes(senderId)) { + logVerbose(`feishu: sender ${senderId} not in group allowlist`); + return false; + } + } else { + // DM policy + const dmPolicy = this.account.config.dmPolicy ?? "pairing"; + if (dmPolicy === "disabled") { + logVerbose(`feishu: DMs disabled`); + return false; + } + + if (dmPolicy === "allowlist") { + const allowFrom = this.opts.allowFrom ?? this.account.config.allowFrom ?? []; + if (allowFrom.length > 0 && !allowFrom.includes(senderId)) { + logVerbose(`feishu: sender ${senderId} not in allowlist`); + return false; + } + } + } + + return true; + } + + /** + * Check if mention is required in this chat + */ + private requiresMention(ctx: FeishuMessageContext): boolean { + if (ctx.chatType !== "group") return false; + + // Check explicit requireMention option + if (this.opts.requireMention !== undefined) { + return this.opts.requireMention; + } + + // Check config-based mention requirement + return resolveChannelGroupRequireMention({ + cfg: this.cfg, + channel: "feishu", + accountId: this.account.accountId, + groupId: ctx.chatId, + }); + } + + /** + * Process a message and determine if a response should be generated + */ + shouldRespond(ctx: FeishuMessageContext): boolean { + // Skip if sender is not allowed + if (!this.isAllowed(ctx)) { + return false; + } + + // Check mention requirement for groups + if (this.requiresMention(ctx) && !ctx.wasMentioned) { + logVerbose(`feishu: mention required but not mentioned in group ${ctx.chatId}`); + return false; + } + + // Skip empty messages + if (!ctx.text.trim()) { + return false; + } + + return true; + } + + /** + * Add message to group history + */ + recordHistory(ctx: FeishuMessageContext, response?: string): void { + if (this.historyLimit === 0) return; + if (ctx.chatType !== "group") return; + + const key = ctx.chatId; + let history = this.groupHistories.get(key); + if (!history) { + history = []; + this.groupHistories.set(key, history); + } + + // Add user message + history.push({ + sender: ctx.senderId, + body: ctx.text, + timestamp: Date.now(), + messageId: ctx.messageId, + }); + + // Add assistant response if provided + if (response) { + history.push({ + sender: "assistant", + body: response, + timestamp: Date.now(), + }); + } + + // Trim to limit + while (history.length > this.historyLimit * 2) { + history.shift(); + } + } + + /** + * Get group history for context + */ + getHistory(chatId: string): HistoryEntry[] { + return this.groupHistories.get(chatId) ?? []; + } + + /** + * Handle an incoming message + * This is called by the monitor when a message is received + */ + async handleMessage(ctx: FeishuMessageContext): Promise { + try { + if (!this.shouldRespond(ctx)) { + return; + } + + // Record the incoming message + this.recordHistory(ctx); + + // Log the message + logVerbose( + `feishu: received message from ${ctx.senderId} in ${ctx.chatType} ${ctx.chatId}: ${ctx.text.substring(0, 100)}`, + ); + + // Here you would integrate with the agent system to generate a response + // For now, we'll just echo back the message as a placeholder + // In production, this would be replaced with actual agent logic + + // Example response (placeholder) + // const response = await generateAgentResponse(ctx); + // await ctx.reply(response); + // this.recordHistory(ctx, response); + } catch (err) { + this.runtime.error?.(danger(`feishu: message handler error: ${formatUncaughtError(err)}`)); + } + } +} + +/** + * Create a Feishu bot instance + */ +export function createFeishuBot(opts: FeishuBotOptions): FeishuBot { + return new FeishuBot(opts); +} + +/** + * Build session key for a Feishu chat + */ +export function buildFeishuSessionKey(params: { + agentId?: string; + chatId: string; + chatType: "p2p" | "group"; + cfg?: ClawdbotConfig; +}): string { + const cfg = params.cfg ?? loadConfig(); + const agentId = params.agentId ?? resolveDefaultAgentId(cfg); + const chatKind = params.chatType === "group" ? "group" : "dm"; + return `agent:${agentId}:feishu:${chatKind}:${params.chatId}`; +} + +/** + * Build peer ID for a Feishu chat + */ +export function buildFeishuPeerId(chatId: string, threadId?: string): string { + return threadId ? `${chatId}:${threadId}` : chatId; +} diff --git a/src/feishu/client.ts b/src/feishu/client.ts new file mode 100644 index 000000000..2cced527a --- /dev/null +++ b/src/feishu/client.ts @@ -0,0 +1,488 @@ +/** + * Feishu (Lark) API Client + * + * Handles authentication and API calls to Feishu Open Platform. + * API Base: https://open.feishu.cn/open-apis + */ + +import type { FeishuCredentials } from "./token.js"; + +const FEISHU_API_BASE = "https://open.feishu.cn/open-apis"; + +export type FeishuApiError = { + code: number; + msg: string; +}; + +export type FeishuApiResponse = { + code: number; + msg: string; + data?: T; +}; + +export type FeishuTenantAccessToken = { + tenant_access_token: string; + expire: number; +}; + +export type FeishuMessageContent = { + text?: string; + post?: FeishuPostContent; + image_key?: string; + file_key?: string; +}; + +export type FeishuPostContent = { + zh_cn?: { + title?: string; + content: FeishuPostElement[][]; + }; + en_us?: { + title?: string; + content: FeishuPostElement[][]; + }; +}; + +export type FeishuPostElement = + | { tag: "text"; text: string } + | { tag: "a"; text: string; href: string } + | { tag: "at"; user_id: string; user_name?: string } + | { tag: "img"; image_key: string } + | { tag: "media"; file_key: string }; + +export type FeishuSendMessageParams = { + receive_id: string; + receive_id_type: "chat_id" | "open_id" | "user_id" | "union_id" | "email"; + msg_type: "text" | "post" | "image" | "file" | "audio" | "media" | "sticker" | "interactive"; + content: string; // JSON stringified content + uuid?: string; // Idempotency key +}; + +export type FeishuSendMessageResult = { + message_id: string; + root_id?: string; + parent_id?: string; + thread_id?: string; + msg_type: string; + create_time: string; + update_time: string; + deleted: boolean; + updated: boolean; + chat_id: string; + sender: { + id: string; + id_type: string; + sender_type: string; + }; + body: { + content: string; + }; +}; + +export type FeishuChatInfo = { + chat_id: string; + name?: string; + description?: string; + avatar?: string; + owner_id?: string; + owner_id_type?: string; + chat_mode?: string; + chat_type?: string; + chat_tag?: string; + external?: boolean; + tenant_key?: string; +}; + +export type FeishuUser = { + user_id?: string; + open_id?: string; + union_id?: string; + name?: string; + en_name?: string; + nickname?: string; + email?: string; + mobile?: string; + avatar?: { + avatar_72?: string; + avatar_240?: string; + avatar_640?: string; + avatar_origin?: string; + }; +}; + +// Token cache for tenant access tokens +const tokenCache = new Map(); + +/** + * Get tenant access token for a Feishu app + */ +export async function getTenantAccessToken(credentials: FeishuCredentials): Promise { + const cacheKey = `${credentials.appId}`; + const cached = tokenCache.get(cacheKey); + + // Return cached token if still valid (with 60s buffer) + if (cached && cached.expiresAt > Date.now() + 60_000) { + return cached.token; + } + + const url = `${FEISHU_API_BASE}/auth/v3/tenant_access_token/internal`; + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + app_id: credentials.appId, + app_secret: credentials.appSecret, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to get tenant access token: ${response.status} ${response.statusText}`); + } + + const result = (await response.json()) as FeishuApiResponse & { + tenant_access_token?: string; + expire?: number; + }; + + if (result.code !== 0) { + throw new Error(`Feishu API error: ${result.code} ${result.msg}`); + } + + // Handle both nested and flat response formats + const token = result.data?.tenant_access_token ?? result.tenant_access_token; + const expire = result.data?.expire ?? result.expire ?? 7200; + + if (!token) { + throw new Error("No tenant_access_token in response"); + } + + // Cache the token + tokenCache.set(cacheKey, { + token, + expiresAt: Date.now() + expire * 1000, + }); + + return token; +} + +/** + * Feishu API client instance + */ +export class FeishuClient { + private credentials: FeishuCredentials; + private timeoutMs: number; + + constructor(credentials: FeishuCredentials, opts?: { timeoutMs?: number }) { + this.credentials = credentials; + this.timeoutMs = opts?.timeoutMs ?? 30_000; + } + + /** + * Make an authenticated API request + */ + async request( + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + path: string, + opts?: { + params?: Record; + body?: unknown; + headers?: Record; + }, + ): Promise> { + const token = await getTenantAccessToken(this.credentials); + + let url = `${FEISHU_API_BASE}${path}`; + if (opts?.params && Object.keys(opts.params).length > 0) { + const searchParams = new URLSearchParams(opts.params); + url += `?${searchParams.toString()}`; + } + + const headers: Record = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...opts?.headers, + }; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const fetchOpts: RequestInit = { + method, + headers, + signal: controller.signal, + }; + // Only include body for methods that support it + if (opts?.body && method !== "GET") { + fetchOpts.body = JSON.stringify(opts.body); + } + const response = await fetch(url, fetchOpts); + + if (!response.ok) { + // Try to read error details from response body + let errorDetail = ""; + try { + const errorBody = (await response.json()) as { code?: number; msg?: string }; + if (errorBody.code !== undefined || errorBody.msg) { + errorDetail = ` (code: ${errorBody.code}, msg: ${errorBody.msg})`; + } + } catch { + // Ignore JSON parse errors + } + throw new Error( + `Feishu API request failed: ${response.status} ${response.statusText}${errorDetail}`, + ); + } + + return (await response.json()) as FeishuApiResponse; + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Send a message to a chat + */ + async sendMessage(params: FeishuSendMessageParams): Promise { + const result = await this.request("POST", "/im/v1/messages", { + params: { receive_id_type: params.receive_id_type }, + body: { + receive_id: params.receive_id, + msg_type: params.msg_type, + content: params.content, + ...(params.uuid ? { uuid: params.uuid } : {}), + }, + }); + + if (result.code !== 0) { + throw new Error(`Failed to send message: ${result.code} ${result.msg}`); + } + + if (!result.data) { + throw new Error("No data in send message response"); + } + + return result.data; + } + + /** + * Send a text message + */ + async sendTextMessage( + receiveId: string, + text: string, + receiveIdType: FeishuSendMessageParams["receive_id_type"] = "chat_id", + ): Promise { + return this.sendMessage({ + receive_id: receiveId, + receive_id_type: receiveIdType, + msg_type: "text", + content: JSON.stringify({ text }), + }); + } + + /** + * Send a post (rich text) message + */ + async sendPostMessage( + receiveId: string, + post: FeishuPostContent, + receiveIdType: FeishuSendMessageParams["receive_id_type"] = "chat_id", + ): Promise { + return this.sendMessage({ + receive_id: receiveId, + receive_id_type: receiveIdType, + msg_type: "post", + content: JSON.stringify({ post }), + }); + } + + /** + * Reply to a message + */ + async replyMessage( + messageId: string, + msgType: FeishuSendMessageParams["msg_type"], + content: string, + ): Promise { + const result = await this.request( + "POST", + `/im/v1/messages/${messageId}/reply`, + { + body: { + msg_type: msgType, + content, + }, + }, + ); + + if (result.code !== 0) { + throw new Error(`Failed to reply to message: ${result.code} ${result.msg}`); + } + + if (!result.data) { + throw new Error("No data in reply message response"); + } + + return result.data; + } + + /** + * List chats the bot has joined + */ + async listChats(opts?: { + pageSize?: number; + pageToken?: string; + }): Promise<{ items: FeishuChatInfo[]; has_more: boolean; page_token?: string }> { + const params: Record = {}; + if (opts?.pageSize) params.page_size = String(opts.pageSize); + if (opts?.pageToken) params.page_token = opts.pageToken; + + const result = await this.request<{ + items: FeishuChatInfo[]; + has_more: boolean; + page_token?: string; + }>("GET", "/im/v1/chats", { params }); + + if (result.code !== 0) { + throw new Error(`Failed to list chats: ${result.code} ${result.msg}`); + } + + return result.data ?? { items: [], has_more: false }; + } + + /** + * Get chat info by chat_id + */ + async getChatInfo(chatId: string): Promise { + const result = await this.request("GET", `/im/v1/chats/${chatId}`); + + if (result.code !== 0) { + throw new Error(`Failed to get chat info: ${result.code} ${result.msg}`); + } + + if (!result.data) { + throw new Error("No data in chat info response"); + } + + return result.data; + } + + /** + * Get chat members + */ + async getChatMembers( + chatId: string, + opts?: { pageSize?: number; pageToken?: string }, + ): Promise<{ items: FeishuUser[]; has_more: boolean; page_token?: string }> { + const params: Record = { member_id_type: "open_id" }; + if (opts?.pageSize) params.page_size = String(opts.pageSize); + if (opts?.pageToken) params.page_token = opts.pageToken; + + const result = await this.request<{ + items: Array<{ member_id: string; member_id_type: string; name?: string }>; + has_more: boolean; + page_token?: string; + }>("GET", `/im/v1/chats/${chatId}/members`, { params }); + + if (result.code !== 0) { + throw new Error(`Failed to get chat members: ${result.code} ${result.msg}`); + } + + const items: FeishuUser[] = (result.data?.items ?? []).map((item) => ({ + open_id: item.member_id, + name: item.name, + })); + + return { + items, + has_more: result.data?.has_more ?? false, + page_token: result.data?.page_token, + }; + } + + /** + * Delete a message + */ + async deleteMessage(messageId: string): Promise { + const result = await this.request("DELETE", `/im/v1/messages/${messageId}`); + + if (result.code !== 0) { + throw new Error(`Failed to delete message: ${result.code} ${result.msg}`); + } + } + + /** + * Update a message + */ + async updateMessage(messageId: string, msgType: "text" | "post", content: string): Promise { + const result = await this.request("PATCH", `/im/v1/messages/${messageId}`, { + body: { + msg_type: msgType, + content, + }, + }); + + if (result.code !== 0) { + throw new Error(`Failed to update message: ${result.code} ${result.msg}`); + } + } + + /** + * Get bot info + */ + async getBotInfo(): Promise<{ + app_name: string; + open_id: string; + activate_status?: number; + }> { + // The /bot/v3/info endpoint returns bot at top level, not in data + type BotInfoResponse = { + code: number; + msg: string; + bot?: { + app_name: string; + open_id: string; + activate_status?: number; + avatar_url?: string; + }; + }; + + const token = await getTenantAccessToken(this.credentials); + const url = `${FEISHU_API_BASE}/bot/v3/info`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to get bot info: ${response.status} ${response.statusText}`); + } + + const result = (await response.json()) as BotInfoResponse; + + if (result.code !== 0) { + throw new Error(`Failed to get bot info: ${result.code} ${result.msg}`); + } + + if (!result.bot) { + throw new Error("No bot info in response"); + } + + return result.bot; + } +} + +/** + * Create a Feishu client from credentials + */ +export function createFeishuClient( + credentials: FeishuCredentials, + opts?: { timeoutMs?: number }, +): FeishuClient { + return new FeishuClient(credentials, opts); +} diff --git a/src/feishu/events.ts b/src/feishu/events.ts new file mode 100644 index 000000000..22344113d --- /dev/null +++ b/src/feishu/events.ts @@ -0,0 +1,328 @@ +/** + * Feishu Event Types + * + * Handles event parsing and decryption for Feishu webhook/websocket events. + */ + +import crypto from "node:crypto"; + +/** + * Base event structure from Feishu + */ +export type FeishuEventBase = { + schema: string; + header: { + event_id: string; + event_type: string; + create_time: string; + token: string; + app_id: string; + tenant_key: string; + }; +}; + +/** + * Message receive event (im.message.receive_v1) + */ +export type FeishuMessageReceiveEvent = FeishuEventBase & { + header: FeishuEventBase["header"] & { + event_type: "im.message.receive_v1"; + }; + event: { + sender: { + sender_id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + sender_type: string; + tenant_key?: string; + }; + message: { + message_id: string; + root_id?: string; + parent_id?: string; + thread_id?: string; + create_time: string; + update_time?: string; + chat_id: string; + chat_type: "p2p" | "group"; + message_type: string; + content: string; // JSON stringified + mentions?: Array<{ + key: string; + id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + name: string; + tenant_key?: string; + }>; + }; + }; +}; + +/** + * Message read event (im.message.message_read_v1) + */ +export type FeishuMessageReadEvent = FeishuEventBase & { + header: FeishuEventBase["header"] & { + event_type: "im.message.message_read_v1"; + }; + event: { + reader: { + reader_id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + read_time: string; + tenant_key?: string; + }; + message_id_list: string[]; + }; +}; + +/** + * Chat member added event (im.chat.member.user.added_v1) + */ +export type FeishuChatMemberAddedEvent = FeishuEventBase & { + header: FeishuEventBase["header"] & { + event_type: "im.chat.member.user.added_v1"; + }; + event: { + chat_id: string; + operator_id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + users: Array<{ + user_id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + tenant_key?: string; + name?: string; + }>; + name?: string; + i18n_names?: { + zh_cn?: string; + en_us?: string; + ja_jp?: string; + }; + }; +}; + +/** + * Bot added to chat event (im.chat.member.bot.added_v1) + */ +export type FeishuBotAddedEvent = FeishuEventBase & { + header: FeishuEventBase["header"] & { + event_type: "im.chat.member.bot.added_v1"; + }; + event: { + chat_id: string; + operator_id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + name?: string; + i18n_names?: { + zh_cn?: string; + en_us?: string; + ja_jp?: string; + }; + }; +}; + +/** + * Message reaction event (im.message.reaction.created_v1) + */ +export type FeishuMessageReactionCreatedEvent = FeishuEventBase & { + header: FeishuEventBase["header"] & { + event_type: "im.message.reaction.created_v1"; + }; + event: { + message_id: string; + reaction_type: { + emoji_type: string; + }; + operator_type: string; + user_id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + action_time: string; + }; +}; + +/** + * URL verification challenge (webhook setup) + */ +export type FeishuUrlVerificationEvent = { + type: "url_verification"; + token: string; + challenge: string; +}; + +/** + * Union type for all supported events + */ +export type FeishuEvent = + | FeishuMessageReceiveEvent + | FeishuMessageReadEvent + | FeishuChatMemberAddedEvent + | FeishuBotAddedEvent + | FeishuMessageReactionCreatedEvent; + +/** + * Raw event payload (may be encrypted) + */ +export type FeishuRawEventPayload = FeishuUrlVerificationEvent | FeishuEvent | { encrypt: string }; + +/** + * Decrypt Feishu event payload + */ +export function decryptFeishuEvent(encrypted: string, encryptKey: string): unknown { + // Feishu uses AES-256-CBC with the encryptKey as the key + const key = crypto.createHash("sha256").update(encryptKey).digest(); + const encryptedBuffer = Buffer.from(encrypted, "base64"); + + // First 16 bytes are the IV + const iv = encryptedBuffer.subarray(0, 16); + const data = encryptedBuffer.subarray(16); + + const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); + let decrypted = decipher.update(data); + decrypted = Buffer.concat([decrypted, decipher.final()]); + + return JSON.parse(decrypted.toString("utf-8")); +} + +/** + * Verify event signature + */ +export function verifyFeishuEventSignature( + timestamp: string, + nonce: string, + encryptKey: string, + body: string, + signature: string, +): boolean { + const content = timestamp + nonce + encryptKey + body; + const computedSignature = crypto.createHash("sha256").update(content).digest("hex"); + return computedSignature === signature; +} + +/** + * Parse and optionally decrypt an event payload + */ +export function parseFeishuEvent( + payload: unknown, + encryptKey?: string, +): FeishuRawEventPayload | null { + if (!payload || typeof payload !== "object") return null; + + const raw = payload as Record; + + // Check if encrypted + if (typeof raw.encrypt === "string" && encryptKey) { + try { + const decrypted = decryptFeishuEvent(raw.encrypt, encryptKey); + return decrypted as FeishuRawEventPayload; + } catch { + return null; + } + } + + return payload as FeishuRawEventPayload; +} + +/** + * Check if event is URL verification challenge + */ +export function isUrlVerificationEvent( + event: FeishuRawEventPayload, +): event is FeishuUrlVerificationEvent { + return "type" in event && event.type === "url_verification"; +} + +/** + * Check if event is a message receive event + */ +export function isMessageReceiveEvent( + event: FeishuRawEventPayload, +): event is FeishuMessageReceiveEvent { + return "header" in event && event.header?.event_type === "im.message.receive_v1"; +} + +/** + * Check if event is a reaction event + */ +export function isReactionCreatedEvent( + event: FeishuRawEventPayload, +): event is FeishuMessageReactionCreatedEvent { + return "header" in event && event.header?.event_type === "im.message.reaction.created_v1"; +} + +/** + * Check if event is a bot added event + */ +export function isBotAddedEvent(event: FeishuRawEventPayload): event is FeishuBotAddedEvent { + return "header" in event && event.header?.event_type === "im.chat.member.bot.added_v1"; +} + +/** + * Parse message content from event + */ +export function parseMessageContent(contentJson: string): { + text?: string; + imageKey?: string; + fileKey?: string; + raw: unknown; +} { + try { + const content = JSON.parse(contentJson) as Record; + return { + text: typeof content.text === "string" ? content.text : undefined, + imageKey: typeof content.image_key === "string" ? content.image_key : undefined, + fileKey: typeof content.file_key === "string" ? content.file_key : undefined, + raw: content, + }; + } catch { + return { raw: contentJson }; + } +} + +/** + * Extract mention from message text + */ +export function extractMentionedText( + text: string, + mentions?: FeishuMessageReceiveEvent["event"]["message"]["mentions"], +): { strippedText: string; wasMentioned: boolean; mentionedBotId?: string } { + if (!mentions || mentions.length === 0) { + return { strippedText: text, wasMentioned: false }; + } + + let strippedText = text; + let wasMentioned = false; + let mentionedBotId: string | undefined; + + for (const mention of mentions) { + // Feishu mentions appear as @_user_ in text + const mentionPattern = new RegExp(`@_user_\\d+`, "g"); + if (mentionPattern.test(strippedText)) { + wasMentioned = true; + mentionedBotId = mention.id.open_id; + strippedText = strippedText.replace(mentionPattern, "").trim(); + } + } + + return { strippedText, wasMentioned, mentionedBotId }; +} diff --git a/src/feishu/index.ts b/src/feishu/index.ts new file mode 100644 index 000000000..e67993908 --- /dev/null +++ b/src/feishu/index.ts @@ -0,0 +1,19 @@ +// Feishu channel public exports + +export { createFeishuBot, buildFeishuSessionKey, buildFeishuPeerId } from "./bot.js"; +export { createFeishuClient, type FeishuClient } from "./client.js"; +export { monitorFeishuProvider, createFeishuWebhookHandler } from "./monitor.js"; +export { + sendMessageFeishu, + reactMessageFeishu, + deleteMessageFeishu, + editMessageFeishu, +} from "./send.js"; +export { + resolveFeishuAccount, + listFeishuAccountIds, + listEnabledFeishuAccounts, +} from "./accounts.js"; +export { resolveFeishuCredentials } from "./token.js"; +export type { FeishuMessageContext, MonitorFeishuOpts } from "./monitor.js"; +export type { FeishuBotOptions } from "./bot.js"; diff --git a/src/feishu/message-dispatch.ts b/src/feishu/message-dispatch.ts new file mode 100644 index 000000000..069dc410e --- /dev/null +++ b/src/feishu/message-dispatch.ts @@ -0,0 +1,272 @@ +/** + * Feishu Message Dispatch + * + * Routes incoming Feishu messages through the agent dispatch system, + * similar to how Telegram messages are handled. + */ + +import type { ClawdbotConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { ResolvedFeishuAccount } from "./accounts.js"; +import type { FeishuMessageContext } from "./monitor.js"; +import { resolveAgentRoute } from "../routing/resolve-route.js"; +import { dispatchInboundMessageWithBufferedDispatcher } from "../auto-reply/dispatch.js"; +import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js"; +import { hasControlCommand } from "../auto-reply/command-detection.js"; +import { normalizeCommandBody } from "../auto-reply/commands-registry.js"; +import { + resolveChannelGroupPolicy, + resolveChannelGroupRequireMention, +} from "../config/group-policy.js"; +import { danger } from "../globals.js"; +import { recordChannelActivity } from "../infra/channel-activity.js"; +import { createDedupeCache } from "../infra/dedupe.js"; +import { formatUncaughtError } from "../infra/errors.js"; +import { sendMessageFeishu } from "./send.js"; + +// Message deduplication cache to prevent processing duplicate messages +const feishuMessageDedupe = createDedupeCache({ + maxSize: 1000, + ttlMs: 60_000, // 1 minute +}); + +export type DispatchFeishuMessageParams = { + ctx: FeishuMessageContext; + cfg: ClawdbotConfig; + runtime?: RuntimeEnv; + account: ResolvedFeishuAccount; +}; + +/** + * Build peer ID for Feishu (group may include thread) + */ +function buildFeishuPeerId(chatId: string, threadId?: string): string { + return threadId ? `${chatId}:${threadId}` : chatId; +} + +/** + * Check if sender is allowed based on config + */ +function isFeishuSenderAllowed( + ctx: FeishuMessageContext, + cfg: ClawdbotConfig, + account: ResolvedFeishuAccount, +): { allowed: boolean; reason?: string } { + const isGroup = ctx.chatType === "group"; + const senderId = ctx.senderId; + + if (isGroup) { + // Check group policy + const groupPolicySetting = account.config.groupPolicy ?? "open"; + if (groupPolicySetting === "disabled") { + return { allowed: false, reason: "group_disabled" }; + } + + const groupPolicy = resolveChannelGroupPolicy({ + cfg, + channel: "feishu", + accountId: account.accountId, + groupId: ctx.chatId, + }); + + if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) { + return { allowed: false, reason: "group_not_in_allowlist" }; + } + + // Check groupAllowFrom for sender filtering + const groupAllowFrom = account.config.groupAllowFrom ?? []; + if (groupAllowFrom.length > 0 && !groupAllowFrom.includes(senderId)) { + return { allowed: false, reason: "sender_not_in_group_allowlist" }; + } + } else { + // DM policy + const dmPolicy = account.config.dmPolicy ?? "pairing"; + if (dmPolicy === "disabled") { + return { allowed: false, reason: "dm_disabled" }; + } + + if (dmPolicy === "allowlist") { + const allowFrom = account.config.allowFrom ?? []; + if (allowFrom.length > 0 && !allowFrom.includes(senderId)) { + return { allowed: false, reason: "sender_not_in_allowlist" }; + } + } + + // TODO: Handle pairing mode for unknown DMs + // For now, allow through if not explicitly blocked + } + + return { allowed: true }; +} + +/** + * Check if mention is required in group chats + */ +function requiresFeishuMention( + ctx: FeishuMessageContext, + cfg: ClawdbotConfig, + account: ResolvedFeishuAccount, +): boolean { + if (ctx.chatType !== "group") return false; + + return resolveChannelGroupRequireMention({ + cfg, + channel: "feishu", + accountId: account.accountId, + groupId: ctx.chatId, + }); +} + +/** + * Dispatch a Feishu message to the agent system + */ +export async function dispatchFeishuMessage(params: DispatchFeishuMessageParams): Promise { + const { ctx, cfg, runtime, account } = params; + const log = runtime?.log ?? console.log; + + // Deduplicate messages to prevent processing the same message twice + const dedupeKey = `feishu:${ctx.messageId}`; + if (feishuMessageDedupe.check(dedupeKey)) { + log(`feishu: skipping duplicate message ${ctx.messageId}`); + return; + } + + log( + `feishu: dispatchFeishuMessage called - chatId=${ctx.chatId}, senderId=${ctx.senderId}, chatType=${ctx.chatType}`, + ); + + // Record channel activity + recordChannelActivity({ + channel: "feishu", + accountId: account.accountId, + direction: "inbound", + }); + + const isGroup = ctx.chatType === "group"; + const peerId = buildFeishuPeerId(ctx.chatId, ctx.threadId); + + // Resolve agent route for session key + const route = resolveAgentRoute({ + cfg, + channel: "feishu", + accountId: account.accountId, + peer: { + kind: isGroup ? "group" : "dm", + id: peerId, + }, + }); + log(`feishu: resolved route - sessionKey=${route.sessionKey}, agentId=${route.agentId}`); + + // Check if sender is allowed + const accessCheck = isFeishuSenderAllowed(ctx, cfg, account); + if (!accessCheck.allowed) { + log(`feishu: blocked message from ${ctx.senderId} (${accessCheck.reason})`); + return; + } + log(`feishu: sender allowed`); + + // Check mention requirement for groups + if (isGroup && requiresFeishuMention(ctx, cfg, account) && !ctx.wasMentioned) { + log(`feishu: mention required but not mentioned in group ${ctx.chatId}`); + return; + } + + // Skip empty messages + const messageText = ctx.text.trim(); + if (!messageText) { + log(`feishu: skipping empty message`); + return; + } + + // Normalize command body (strip bot mention prefix if present) + const commandBody = normalizeCommandBody(messageText); + + // Check if this is a control command + const isControlCommand = hasControlCommand(commandBody, cfg, undefined); + + log( + `feishu: processing message - text="${messageText.substring(0, 100)}", isCommand=${isControlCommand}`, + ); + + // Build message context for dispatch + const envelopeOpts = resolveEnvelopeFormatOptions(cfg); + const conversationLabel = isGroup ? `group:${ctx.chatId}` : ctx.senderId; + + // Format envelope for agent context + const bodyForAgent = formatInboundEnvelope({ + channel: "Feishu", + from: conversationLabel, + body: messageText, + chatType: isGroup ? "group" : "direct", + senderLabel: ctx.senderId, // TODO: resolve sender display name + envelope: envelopeOpts, + }); + + const msgContext = { + Body: bodyForAgent, + BodyForAgent: bodyForAgent, + RawBody: messageText, + CommandBody: commandBody, + BodyForCommands: commandBody, + From: isGroup ? `feishu:group:${ctx.chatId}` : `feishu:${ctx.chatId}`, + To: `feishu:${ctx.chatId}`, + SessionKey: route.sessionKey, + AccountId: account.accountId, + MessageSid: ctx.messageId, + ReplyToId: ctx.replyToMessageId, + ChatType: isGroup ? "group" : "direct", + ConversationLabel: conversationLabel, + Provider: "feishu", + Surface: "feishu", + WasMentioned: ctx.wasMentioned, + SenderId: ctx.senderId, + Timestamp: Date.now(), + MessageThreadId: ctx.threadId, + // Commands are authorized if sender passed access checks + CommandAuthorized: true, + // Originating channel info for reply routing + OriginatingChannel: "feishu" as const, + OriginatingTo: ctx.chatId, + }; + + log(`feishu: dispatching to agent system...`); + + try { + // Dispatch through the agent system + await dispatchInboundMessageWithBufferedDispatcher({ + ctx: msgContext, + cfg, + dispatcherOptions: { + deliver: async (payload) => { + log( + `feishu: deliver callback called - hasText=${!!payload.text}, textLength=${payload.text?.length ?? 0}`, + ); + // Send response back to Feishu + if (payload.text) { + try { + log(`feishu: sending reply to ${ctx.chatId}...`); + await sendMessageFeishu({ + to: ctx.chatId, + text: payload.text, + accountId: account.accountId, + config: cfg, + receiveIdType: "chat_id", + }); + log(`feishu: reply sent successfully`); + } catch (sendErr) { + runtime?.error?.( + danger(`feishu: failed to send reply: ${formatUncaughtError(sendErr)}`), + ); + } + } + }, + onError: (err) => { + runtime?.error?.(danger(`feishu: dispatch error: ${formatUncaughtError(err)}`)); + }, + }, + }); + log(`feishu: dispatch completed`); + } catch (err) { + runtime?.error?.(danger(`feishu: message dispatch failed: ${formatUncaughtError(err)}`)); + } +} diff --git a/src/feishu/monitor.ts b/src/feishu/monitor.ts new file mode 100644 index 000000000..5878c4dc2 --- /dev/null +++ b/src/feishu/monitor.ts @@ -0,0 +1,598 @@ +/** + * Feishu Event Monitor + * + * Supports two modes for receiving events: + * 1. Webhook mode: HTTP server receives events from Feishu + * 2. WebSocket mode: Long connection to Feishu for real-time events + */ + +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import os from "node:os"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { loadConfig } from "../config/config.js"; +import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { formatDurationMs } from "../infra/format-duration.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveFeishuAccount, type ResolvedFeishuAccount } from "./accounts.js"; +import { createFeishuClient, type FeishuClient } from "./client.js"; +import { + parseFeishuEvent, + isUrlVerificationEvent, + isMessageReceiveEvent, + isBotAddedEvent, + parseMessageContent, + extractMentionedText, + type FeishuMessageReceiveEvent, + type FeishuRawEventPayload, +} from "./events.js"; +import { resolveFeishuEncryptKey, resolveFeishuVerificationToken } from "./token.js"; + +export type MonitorFeishuOpts = { + accountId?: string; + config?: ClawdbotConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + /** Override event mode (webhook or websocket) */ + eventMode?: "webhook" | "websocket"; + /** Webhook server port */ + webhookPort?: number; + /** Webhook server path */ + webhookPath?: string; + /** Message handler callback */ + onMessage?: (ctx: FeishuMessageContext) => void | Promise; +}; + +export type FeishuMessageContext = { + event: FeishuMessageReceiveEvent; + messageId: string; + chatId: string; + chatType: "p2p" | "group"; + senderId: string; + senderType: string; + text: string; + rawContent: unknown; + mentions: FeishuMessageReceiveEvent["event"]["message"]["mentions"]; + wasMentioned: boolean; + replyToMessageId?: string; + threadId?: string; + client: FeishuClient; + account: ResolvedFeishuAccount; + cfg: ClawdbotConfig; + runtime?: RuntimeEnv; + /** Reply to this message */ + reply: (text: string) => Promise; +}; + +const FEISHU_RESTART_POLICY = { + initialMs: 2000, + maxMs: 30_000, + factor: 1.8, + jitter: 0.25, +}; + +/** + * Get primary local IPv4 address + */ +function getLocalIPv4(): string { + const nets = os.networkInterfaces(); + const prefer = ["en0", "eth0", "wlan0"]; + for (const name of prefer) { + const list = nets[name]; + const entry = list?.find((n) => n.family === "IPv4" && !n.internal); + if (entry?.address) return entry.address; + } + for (const list of Object.values(nets)) { + const entry = list?.find((n) => n.family === "IPv4" && !n.internal); + if (entry?.address) return entry.address; + } + return "unknown"; +} + +/** + * Process a Feishu message event + */ +function createMessageContext( + event: FeishuMessageReceiveEvent, + client: FeishuClient, + account: ResolvedFeishuAccount, + cfg: ClawdbotConfig, + runtime?: RuntimeEnv, +): FeishuMessageContext { + const msg = event.event.message; + const sender = event.event.sender; + + const content = parseMessageContent(msg.content); + const { strippedText, wasMentioned } = extractMentionedText(content.text ?? "", msg.mentions); + + const ctx: FeishuMessageContext = { + event, + messageId: msg.message_id, + chatId: msg.chat_id, + chatType: msg.chat_type, + senderId: sender.sender_id.open_id ?? sender.sender_id.user_id ?? "", + senderType: sender.sender_type, + text: strippedText, + rawContent: content.raw, + mentions: msg.mentions, + wasMentioned, + replyToMessageId: msg.parent_id, + threadId: msg.thread_id, + client, + account, + cfg, + runtime, + reply: async (text: string) => { + await client.replyMessage(msg.message_id, "text", JSON.stringify({ text })); + }, + }; + + return ctx; +} + +/** + * Start webhook server for receiving Feishu events + */ +async function startWebhookServer( + opts: MonitorFeishuOpts & { + account: ResolvedFeishuAccount; + client: FeishuClient; + }, +): Promise { + const cfg = opts.config ?? loadConfig(); + const port = opts.webhookPort ?? opts.account.config.webhookPort ?? 3000; + const path = opts.webhookPath ?? opts.account.config.webhookPath ?? "/feishu-webhook"; + const verificationToken = resolveFeishuVerificationToken(cfg, { accountId: opts.accountId }); + const encryptKey = resolveFeishuEncryptKey(cfg, { accountId: opts.accountId }); + + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "POST" || req.url !== path) { + res.writeHead(404); + res.end("Not Found"); + return; + } + + try { + // Read body + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + const body = Buffer.concat(chunks).toString("utf-8"); + const payload = JSON.parse(body) as unknown; + + // Parse and possibly decrypt event + const event = parseFeishuEvent(payload, encryptKey); + if (!event) { + res.writeHead(400); + res.end("Invalid event"); + return; + } + + // Handle URL verification challenge + if (isUrlVerificationEvent(event)) { + if (verificationToken && event.token !== verificationToken) { + res.writeHead(401); + res.end("Invalid token"); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ challenge: event.challenge })); + return; + } + + // Verify token for regular events + if (verificationToken && "header" in event && event.header.token !== verificationToken) { + res.writeHead(401); + res.end("Invalid token"); + return; + } + + // Acknowledge receipt immediately + res.writeHead(200); + res.end("ok"); + + // Process event asynchronously + await handleFeishuEvent(event, opts); + } catch (err) { + opts.runtime?.error?.(`feishu webhook error: ${formatErrorMessage(err)}`); + res.writeHead(500); + res.end("Internal Server Error"); + } + }); + + // Handle abort signal + opts.abortSignal?.addEventListener("abort", () => { + server.close(); + }); + + return new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(port, () => { + opts.runtime?.log?.(`feishu: webhook server listening on port ${port}${path}`); + resolve(); + }); + }); +} + +/** + * Handle a parsed Feishu event + */ +async function handleFeishuEvent( + event: FeishuRawEventPayload, + opts: MonitorFeishuOpts & { + account: ResolvedFeishuAccount; + client: FeishuClient; + }, +): Promise { + const cfg = opts.config ?? loadConfig(); + const log = opts.runtime?.log ?? console.log; + + if (isMessageReceiveEvent(event)) { + log(`feishu: handling message receive event`); + const ctx = createMessageContext(event, opts.client, opts.account, cfg, opts.runtime); + log( + `feishu: message context created - chatId=${ctx.chatId}, senderId=${ctx.senderId}, senderType=${ctx.senderType}, text="${ctx.text.substring(0, 100)}"`, + ); + + // Skip bot's own messages + if (ctx.senderType === "bot") { + log(`feishu: skipping bot's own message`); + return; + } + + // Call message handler + if (opts.onMessage) { + log(`feishu: calling onMessage handler`); + try { + await opts.onMessage(ctx); + log(`feishu: onMessage handler completed`); + } catch (err) { + opts.runtime?.error?.(`feishu: message handler error: ${formatErrorMessage(err)}`); + } + } else { + log(`feishu: no onMessage handler registered`); + } + } else if (isBotAddedEvent(event)) { + opts.runtime?.log?.(`feishu: bot added to chat ${event.event.chat_id}`); + } else { + log(`feishu: received unknown event type`); + } +} + +/** + * SDK message event data structure (from @larksuiteoapi/node-sdk) + * Defined inline to avoid compile-time dependency on the SDK + */ +type SdkMessageEventData = { + sender: { + sender_id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + sender_type: string; + tenant_key?: string; + }; + message: { + message_id: string; + root_id?: string; + parent_id?: string; + thread_id?: string; + create_time: string; + update_time?: string; + chat_id: string; + chat_type: string; + message_type: string; + content: string; + mentions?: Array<{ + key: string; + id: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + name: string; + tenant_key?: string; + }>; + }; +}; + +/** + * SDK bot added event data structure + */ +type SdkBotAddedEventData = { + chat_id: string; + operator_id?: { + open_id?: string; + user_id?: string; + union_id?: string; + }; + name?: string; +}; + +/** + * Start WebSocket connection for receiving Feishu events + * + * Uses Feishu's official SDK WSClient for long connection mode. + * This approach: + * - Doesn't require a public IP/domain + * - Works in local development environments + * - No decryption/signature verification needed (auth at connection time) + */ +async function startWebSocketMode( + opts: MonitorFeishuOpts & { + account: ResolvedFeishuAccount; + client: FeishuClient; + }, +): Promise { + const log = opts.runtime?.log ?? console.log; + const error = opts.runtime?.error ?? console.error; + + log(`feishu: starting WebSocket event monitor for account "${opts.account.accountId}"`); + + // Dynamically import the official Feishu SDK + // This allows the core feishu module to work without the SDK installed + // while the extension provides it + // Use string variable to prevent TypeScript from resolving at compile time + const sdkModule = "@larksuiteoapi/node-sdk"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let lark: any; + try { + lark = await import(/* webpackIgnore: true */ sdkModule); + } catch { + throw new Error( + `Feishu WebSocket mode requires ${sdkModule}. ` + + `Install it with: npm install ${sdkModule}\n` + + `Or use webhook mode instead (eventMode: "webhook").`, + ); + } + + const { appId, appSecret } = opts.account.credentials; + + // Create WebSocket client with Feishu domain + // Use warn level to suppress SDK info logs about persistent connection setup + const wsClient = new lark.WSClient({ + appId, + appSecret, + domain: lark.Domain.Feishu, + loggerLevel: lark.LoggerLevel.warn, + }); + + // Create event dispatcher to handle incoming events + const eventDispatcher = new lark.EventDispatcher({ + // encryptKey not needed for WebSocket mode (plaintext after auth) + }).register({ + // Handle message receive events + "im.message.receive_v1": async (data: SdkMessageEventData) => { + try { + log( + `feishu: received message event from ${data.sender?.sender_id?.open_id ?? "unknown"} in chat ${data.message?.chat_id ?? "unknown"}`, + ); + log(`feishu: message content: ${data.message?.content?.substring(0, 200) ?? "(empty)"}`); + // Convert SDK event format to our internal format + const event = convertSdkMessageEvent(data); + await handleFeishuEvent(event, opts); + } catch (err) { + error(`feishu: WebSocket message handler error: ${formatErrorMessage(err)}`); + } + }, + // Handle bot added to chat events + "im.chat.member.bot.added_v1": async (data: SdkBotAddedEventData) => { + log(`feishu: bot added to chat ${data.chat_id}`); + }, + // Handle user entering P2P chat with bot (suppress warning) + "im.chat.access_event.bot_p2p_chat_entered_v1": async (data: { + chat_id?: string; + operator_id?: unknown; + }) => { + log(`feishu: user entered P2P chat ${data.chat_id ?? "unknown"}`); + }, + }); + + // Handle abort signal - close WebSocket connection + let wsAborted = false; + opts.abortSignal?.addEventListener("abort", () => { + wsAborted = true; + // Note: The SDK doesn't expose a direct close method, + // but the process exit will close the connection + }); + + // Start WebSocket connection + log(`feishu: connecting to Feishu WebSocket server...`); + + try { + await wsClient.start({ + eventDispatcher, + }); + log(`feishu: WebSocket connection established`); + } catch (wsErr) { + throw new Error(`Feishu WebSocket connection failed: ${formatErrorMessage(wsErr)}`); + } + + // Keep running until aborted + // The WSClient maintains the connection internally + if (!wsAborted) { + await new Promise((resolve) => { + opts.abortSignal?.addEventListener("abort", () => resolve()); + }); + } +} + +/** + * Convert SDK message event format to our internal FeishuMessageReceiveEvent format + */ +function convertSdkMessageEvent(sdkData: SdkMessageEventData): FeishuMessageReceiveEvent { + return { + schema: "2.0", + header: { + event_id: `ws_${Date.now()}_${Math.random().toString(36).slice(2)}`, + event_type: "im.message.receive_v1", + create_time: sdkData.message.create_time, + token: "", // Not provided in WebSocket mode + app_id: "", // Filled by caller context if needed + tenant_key: sdkData.sender.tenant_key ?? "", + }, + event: { + sender: sdkData.sender, + message: { + ...sdkData.message, + chat_type: sdkData.message.chat_type as "p2p" | "group", + }, + }, + }; +} + +/** + * Main entry point for Feishu event monitoring + */ +export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise { + const cfg = opts.config ?? loadConfig(); + const log = opts.runtime?.log ?? console.log; + const account = resolveFeishuAccount({ + cfg, + accountId: opts.accountId, + }); + + log(`feishu: initializing account "${account.accountId}"...`); + + if (account.credentials.source === "none") { + throw new Error( + `Feishu credentials missing for account "${account.accountId}" (set channels.feishu.appId/appSecret or FEISHU_APP_ID/FEISHU_APP_SECRET env vars).`, + ); + } + + if (!account.enabled) { + throw new Error(`Feishu account "${account.accountId}" is disabled.`); + } + + log( + `feishu: credentials loaded from ${account.credentials.source} (appId: ${account.credentials.appId.slice(0, 8)}...)`, + ); + + const client = createFeishuClient(account.credentials, { + timeoutMs: (account.config.timeoutSeconds ?? 30) * 1000, + }); + + // Verify credentials by getting bot info + log(`feishu: verifying bot credentials...`); + let botName = "unknown"; + let botOpenId = "unknown"; + try { + const botInfo = await client.getBotInfo(); + botName = botInfo.app_name ?? "unknown"; + botOpenId = botInfo.open_id ?? "unknown"; + log(`feishu: connected as "${botName}" (${botOpenId})`); + } catch (err) { + throw new Error(`Feishu authentication failed: ${formatErrorMessage(err)}`); + } + + // Send startup message if startupChatId is configured + const startupChatId = account.config.startupChatId?.trim(); + if (startupChatId) { + const localIp = getLocalIPv4(); + const hostname = os.hostname(); + const version = process.env.CLAWDBOT_VERSION ?? process.env.npm_package_version ?? "unknown"; + const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }); + + const startupMessage = [ + `🤖 飞书机器人启动通知`, + ``, + `机器人: ${botName}`, + `Bot ID: ${botOpenId}`, + `主机名: ${hostname}`, + `本地IP: ${localIp}`, + `版本: ${version}`, + `启动时间: ${timestamp}`, + ].join("\n"); + + try { + await client.sendTextMessage(startupChatId, startupMessage); + log(`feishu: sent startup message to chat ${startupChatId}`); + } catch (err) { + log(`feishu: failed to send startup message: ${formatErrorMessage(err)}`); + } + } + + // Default to websocket mode (recommended, no public URL required) + const eventMode = opts.eventMode ?? account.config.eventMode ?? "websocket"; + log(`feishu: event mode is "${eventMode}"`); + let restartAttempts = 0; + + while (!opts.abortSignal?.aborted) { + try { + if (eventMode === "webhook") { + await startWebhookServer({ ...opts, account, client }); + return; // Webhook server stays running + } else { + await startWebSocketMode({ ...opts, account, client }); + return; + } + } catch (err) { + if (opts.abortSignal?.aborted) { + throw err; + } + + restartAttempts += 1; + const delayMs = computeBackoff(FEISHU_RESTART_POLICY, restartAttempts); + const errMsg = formatErrorMessage(err); + (opts.runtime?.error ?? console.error)( + `Feishu monitor error: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`, + ); + + try { + await sleepWithAbort(delayMs, opts.abortSignal); + } catch (sleepErr) { + if (opts.abortSignal?.aborted) return; + throw sleepErr; + } + } + } +} + +/** + * Create a Feishu webhook handler for use with external HTTP servers + */ +export function createFeishuWebhookHandler(opts: MonitorFeishuOpts = {}) { + const cfg = opts.config ?? loadConfig(); + const account = resolveFeishuAccount({ + cfg, + accountId: opts.accountId, + }); + + if (account.credentials.source === "none") { + throw new Error(`Feishu credentials missing for account "${account.accountId}".`); + } + + const client = createFeishuClient(account.credentials); + const verificationToken = resolveFeishuVerificationToken(cfg, { accountId: opts.accountId }); + const encryptKey = resolveFeishuEncryptKey(cfg, { accountId: opts.accountId }); + + return async (body: unknown): Promise<{ status: number; body: unknown }> => { + const event = parseFeishuEvent(body, encryptKey); + if (!event) { + return { status: 400, body: { error: "Invalid event" } }; + } + + // Handle URL verification + if (isUrlVerificationEvent(event)) { + if (verificationToken && event.token !== verificationToken) { + return { status: 401, body: { error: "Invalid token" } }; + } + return { status: 200, body: { challenge: event.challenge } }; + } + + // Verify token + if (verificationToken && "header" in event && event.header.token !== verificationToken) { + return { status: 401, body: { error: "Invalid token" } }; + } + + // Process event asynchronously (don't await to respond quickly) + handleFeishuEvent(event, { ...opts, account, client }).catch((err) => { + opts.runtime?.error?.(`feishu webhook handler error: ${formatErrorMessage(err)}`); + }); + + return { status: 200, body: { ok: true } }; + }; +} diff --git a/src/feishu/send.ts b/src/feishu/send.ts new file mode 100644 index 000000000..562ae83e8 --- /dev/null +++ b/src/feishu/send.ts @@ -0,0 +1,231 @@ +/** + * Feishu message sending utilities + */ + +import type { ClawdbotConfig } from "../config/config.js"; +import { loadConfig } from "../config/config.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveFeishuAccount } from "./accounts.js"; +import { + createFeishuClient, + type FeishuSendMessageResult, + type FeishuPostContent, +} from "./client.js"; + +export type SendFeishuMessageParams = { + to: string; + text: string; + accountId?: string | null; + config?: ClawdbotConfig; + runtime?: RuntimeEnv; + /** Receive ID type: chat_id (default), open_id, user_id, union_id, email */ + receiveIdType?: "chat_id" | "open_id" | "user_id" | "union_id" | "email"; + /** Message type: text (default) or post */ + msgType?: "text" | "post"; + /** For post messages, the post content */ + postContent?: FeishuPostContent; + /** Reply to a specific message */ + replyToMessageId?: string; +}; + +export type SendFeishuMessageResult = { + success: boolean; + messageId?: string; + error?: string; +}; + +/** + * Send a message via Feishu + */ +export async function sendMessageFeishu( + params: SendFeishuMessageParams, +): Promise { + const cfg = params.config ?? loadConfig(); + const account = resolveFeishuAccount({ + cfg, + accountId: params.accountId, + }); + + if (account.credentials.source === "none") { + return { + success: false, + error: `Feishu credentials missing for account "${account.accountId}" (set channels.feishu.appId/appSecret or FEISHU_APP_ID/FEISHU_APP_SECRET env vars).`, + }; + } + + if (!account.enabled) { + return { + success: false, + error: `Feishu account "${account.accountId}" is disabled.`, + }; + } + + const client = createFeishuClient(account.credentials, { + timeoutMs: (account.config.timeoutSeconds ?? 30) * 1000, + }); + + try { + let result: FeishuSendMessageResult; + + if (params.replyToMessageId) { + // Reply to a specific message + const content = + params.msgType === "post" && params.postContent + ? JSON.stringify({ post: params.postContent }) + : JSON.stringify({ text: params.text }); + + result = await client.replyMessage( + params.replyToMessageId, + params.msgType ?? "text", + content, + ); + } else if (params.msgType === "post" && params.postContent) { + // Send a post (rich text) message + result = await client.sendPostMessage( + params.to, + params.postContent, + params.receiveIdType ?? "chat_id", + ); + } else { + // Send a text message + result = await client.sendTextMessage( + params.to, + params.text, + params.receiveIdType ?? "chat_id", + ); + } + + return { + success: true, + messageId: result.message_id, + }; + } catch (err) { + const errorMsg = formatErrorMessage(err); + params.runtime?.error?.(`feishu: send failed: ${errorMsg}`); + return { + success: false, + error: errorMsg, + }; + } +} + +/** + * React to a message (Feishu supports reactions via emoji) + * Note: This requires additional API permissions + */ +export async function reactMessageFeishu(params: { + messageId: string; + emoji: string; + accountId?: string | null; + config?: ClawdbotConfig; + runtime?: RuntimeEnv; +}): Promise<{ success: boolean; error?: string }> { + const cfg = params.config ?? loadConfig(); + const account = resolveFeishuAccount({ + cfg, + accountId: params.accountId, + }); + + if (account.credentials.source === "none") { + return { + success: false, + error: `Feishu credentials missing for account "${account.accountId}".`, + }; + } + + const client = createFeishuClient(account.credentials); + + try { + // Feishu reaction API endpoint + await client.request("POST", `/im/v1/messages/${params.messageId}/reactions`, { + body: { + reaction_type: { + emoji_type: params.emoji, + }, + }, + }); + + return { success: true }; + } catch (err) { + const errorMsg = formatErrorMessage(err); + params.runtime?.error?.(`feishu: react failed: ${errorMsg}`); + return { success: false, error: errorMsg }; + } +} + +/** + * Delete a message + */ +export async function deleteMessageFeishu(params: { + messageId: string; + accountId?: string | null; + config?: ClawdbotConfig; + runtime?: RuntimeEnv; +}): Promise<{ success: boolean; error?: string }> { + const cfg = params.config ?? loadConfig(); + const account = resolveFeishuAccount({ + cfg, + accountId: params.accountId, + }); + + if (account.credentials.source === "none") { + return { + success: false, + error: `Feishu credentials missing for account "${account.accountId}".`, + }; + } + + const client = createFeishuClient(account.credentials); + + try { + await client.deleteMessage(params.messageId); + return { success: true }; + } catch (err) { + const errorMsg = formatErrorMessage(err); + params.runtime?.error?.(`feishu: delete failed: ${errorMsg}`); + return { success: false, error: errorMsg }; + } +} + +/** + * Edit/update a message + */ +export async function editMessageFeishu(params: { + messageId: string; + text: string; + accountId?: string | null; + config?: ClawdbotConfig; + runtime?: RuntimeEnv; + msgType?: "text" | "post"; + postContent?: FeishuPostContent; +}): Promise<{ success: boolean; error?: string }> { + const cfg = params.config ?? loadConfig(); + const account = resolveFeishuAccount({ + cfg, + accountId: params.accountId, + }); + + if (account.credentials.source === "none") { + return { + success: false, + error: `Feishu credentials missing for account "${account.accountId}".`, + }; + } + + const client = createFeishuClient(account.credentials); + + try { + const content = + params.msgType === "post" && params.postContent + ? JSON.stringify({ post: params.postContent }) + : JSON.stringify({ text: params.text }); + + await client.updateMessage(params.messageId, params.msgType ?? "text", content); + return { success: true }; + } catch (err) { + const errorMsg = formatErrorMessage(err); + params.runtime?.error?.(`feishu: edit failed: ${errorMsg}`); + return { success: false, error: errorMsg }; + } +} diff --git a/src/feishu/token.ts b/src/feishu/token.ts new file mode 100644 index 000000000..7d22d4336 --- /dev/null +++ b/src/feishu/token.ts @@ -0,0 +1,102 @@ +import fs from "node:fs"; + +import type { ClawdbotConfig } from "../config/config.js"; +import type { FeishuAccountConfig } from "../config/types.feishu.js"; +import { resolveUserPath } from "../utils.js"; + +export type FeishuTokenSource = "env" | "secretFile" | "config" | "none"; + +export type FeishuCredentials = { + appId: string; + appSecret: string; + source: FeishuTokenSource; +}; + +function resolveAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): FeishuAccountConfig | undefined { + const accounts = cfg.channels?.feishu?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + const direct = accounts[accountId] as FeishuAccountConfig | undefined; + if (direct) return direct; + const normalized = accountId.toLowerCase().trim(); + const matchKey = Object.keys(accounts).find((key) => key.toLowerCase().trim() === normalized); + return matchKey ? (accounts[matchKey] as FeishuAccountConfig | undefined) : undefined; +} + +function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): FeishuAccountConfig { + const { accounts: _ignored, ...base } = (cfg.channels?.feishu ?? {}) as FeishuAccountConfig & { + accounts?: unknown; + }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +function readSecretFile(filePath: string): string | undefined { + try { + const resolved = resolveUserPath(filePath); + if (!fs.existsSync(resolved)) return undefined; + return fs.readFileSync(resolved, "utf-8").trim(); + } catch { + return undefined; + } +} + +export function resolveFeishuCredentials( + cfg: ClawdbotConfig, + opts?: { accountId?: string | null }, +): FeishuCredentials { + const accountId = opts?.accountId?.trim() || "default"; + const merged = mergeFeishuAccountConfig(cfg, accountId); + + // Try environment variables first + const envAppId = process.env.FEISHU_APP_ID?.trim(); + const envAppSecret = process.env.FEISHU_APP_SECRET?.trim(); + if (envAppId && envAppSecret) { + return { appId: envAppId, appSecret: envAppSecret, source: "env" }; + } + + // Try secret file + if (merged.appSecretFile && merged.appId) { + const secretFromFile = readSecretFile(merged.appSecretFile); + if (secretFromFile) { + return { appId: merged.appId, appSecret: secretFromFile, source: "secretFile" }; + } + } + + // Try config values + if (merged.appId && merged.appSecret) { + return { appId: merged.appId, appSecret: merged.appSecret, source: "config" }; + } + + return { appId: "", appSecret: "", source: "none" }; +} + +export function resolveFeishuVerificationToken( + cfg: ClawdbotConfig, + opts?: { accountId?: string | null }, +): string | undefined { + const accountId = opts?.accountId?.trim() || "default"; + const merged = mergeFeishuAccountConfig(cfg, accountId); + + // Try environment variable first + const envToken = process.env.FEISHU_VERIFICATION_TOKEN?.trim(); + if (envToken) return envToken; + + return merged.verificationToken?.trim(); +} + +export function resolveFeishuEncryptKey( + cfg: ClawdbotConfig, + opts?: { accountId?: string | null }, +): string | undefined { + const accountId = opts?.accountId?.trim() || "default"; + const merged = mergeFeishuAccountConfig(cfg, accountId); + + // Try environment variable first + const envKey = process.env.FEISHU_ENCRYPT_KEY?.trim(); + if (envKey) return envKey; + + return merged.encryptKey?.trim(); +} diff --git a/src/media-understanding/providers/image.ts b/src/media-understanding/providers/image.ts index 0f5ea1756..6f815a581 100644 --- a/src/media-understanding/providers/image.ts +++ b/src/media-understanding/providers/image.ts @@ -3,6 +3,7 @@ import { complete } from "@mariozechner/pi-ai"; import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent"; import { getApiKeyForModel, requireApiKey } from "../../agents/model-auth.js"; +import { setupLlmProxy } from "../../agents/llm-proxy.js"; import { ensureMoltbotModelsJson } from "../../agents/models-config.js"; import { minimaxUnderstandImage } from "../../agents/minimax-vlm.js"; import { coerceImageAssistantText } from "../../agents/tools/image-tool.helpers.js"; @@ -11,6 +12,9 @@ import type { ImageDescriptionRequest, ImageDescriptionResult } from "../types.j export async function describeImageWithModel( params: ImageDescriptionRequest, ): Promise { + // Set up LLM proxy if configured (must be done before any fetch calls) + setupLlmProxy(params.cfg); + await ensureMoltbotModelsJson(params.cfg, params.agentDir); const authStorage = discoverAuthStorage(params.agentDir); const modelRegistry = discoverModels(authStorage, params.agentDir); diff --git a/test_scripts/feishu_server.py b/test_scripts/feishu_server.py new file mode 100644 index 000000000..d230671f1 --- /dev/null +++ b/test_scripts/feishu_server.py @@ -0,0 +1,22 @@ +import lark_oapi as lark + +def do_p2_im_message_receive_v1(data: lark.im.v1.P2ImMessageReceiveV1) -> None: + print(f'[do_p2_im_message_receive_v1 access], data: {lark.JSON.marshal(data, indent=4)}') + +def do_message_event(data: lark.CustomizedEvent) -> None: + print(f'[do_customized_event access], type: message, data: {lark.JSON.marshal(data, indent=4)}') + +# 初始化事件处理器,两个参数必须为空字符串 +event_handler = lark.EventDispatcherHandler.builder("", "") \ + .register_p2_im_message_receive_v1(do_p2_im_message_receive_v1) \ + .register_p1_customized_event("这里填入你要自定义订阅的event的key", do_message_event) \ + .build() + +def main(): + # 创建客户端,填入您的APP_ID和APP_SECRET + cli = lark.ws.Client("cli_a9f2dc95fe78dbcb", "9DUETQZa83rvn12aeADpsbC1ioi1bkNf", event_handler=event_handler, log_level=lark.LogLevel.DEBUG) + cli.start() + +if __name__ == "__main__": + main() + diff --git a/test_scripts/get_bitable_info.py b/test_scripts/get_bitable_info.py new file mode 100755 index 000000000..37ced17dd --- /dev/null +++ b/test_scripts/get_bitable_info.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +获取飞书多维表格的正确 app_token 和 table_id + +飞书多维表格有两种访问方式: +1. 独立的多维表格:https://xxx.feishu.cn/base/{app_token}?table={table_id} +2. 知识库中的多维表格:https://xxx.feishu.cn/wiki/{wiki_token}?table={table_id} + +对于知识库中的多维表格,需要通过 API 获取实际的 app_token。 + +使用方法: + export FEISHU_APP_ID="cli_xxxxxxxxxxxxxxxx" + export FEISHU_APP_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxx" + python scripts/feishu/get_bitable_info.py +""" + +import os +import sys +import re +from urllib.parse import urlparse, parse_qs + +import requests + +FEISHU_API_BASE = "https://open.feishu.cn/open-apis" + + +def get_tenant_access_token(app_id: str, app_secret: str) -> str: + """获取租户访问令牌""" + url = f"{FEISHU_API_BASE}/auth/v3/tenant_access_token/internal" + payload = { + "app_id": app_id, + "app_secret": app_secret, + } + + response = requests.post(url, json=payload, timeout=30) + response.raise_for_status() + + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"获取访问令牌失败: {data.get('msg')}") + + return data["tenant_access_token"] + + +def parse_feishu_url(url: str) -> dict: + """解析飞书多维表格 URL""" + parsed = urlparse(url) + path_parts = parsed.path.strip('/').split('/') + query_params = parse_qs(parsed.query) + + result = { + "url_type": None, + "token": None, + "table_id": query_params.get('table', [None])[0], + "view_id": query_params.get('view', [None])[0], + } + + if 'wiki' in path_parts: + result["url_type"] = "wiki" + wiki_idx = path_parts.index('wiki') + if len(path_parts) > wiki_idx + 1: + result["token"] = path_parts[wiki_idx + 1] + elif 'base' in path_parts: + result["url_type"] = "base" + base_idx = path_parts.index('base') + if len(path_parts) > base_idx + 1: + result["token"] = path_parts[base_idx + 1] + + return result + + +def get_wiki_node_info(token: str, wiki_token: str) -> dict: + """获取知识库节点信息""" + url = f"{FEISHU_API_BASE}/wiki/v2/spaces/get_node" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json; charset=utf-8", + } + params = {"token": wiki_token} + + response = requests.get(url, headers=headers, params=params, timeout=30) + + try: + data = response.json() + except Exception: + response.raise_for_status() + raise RuntimeError(f"无法解析飞书 API 响应: {response.text[:200]}") + + if data.get("code") != 0: + error_msg = data.get("msg", "未知错误") + error_code = data.get("code") + raise RuntimeError( + f"获取知识库节点信息失败 (错误码: {error_code}): {error_msg}" + ) + + return data.get("data", {}).get("node", {}) + + +def list_tables_in_base(token: str, app_token: str) -> list: + """列出多维表格中的所有数据表""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json; charset=utf-8", + } + + response = requests.get(url, headers=headers, timeout=30) + + try: + data = response.json() + except Exception: + response.raise_for_status() + raise RuntimeError(f"无法解析飞书 API 响应: {response.text[:200]}") + + if data.get("code") != 0: + error_msg = data.get("msg", "未知错误") + error_code = data.get("code") + raise RuntimeError( + f"获取数据表列表失败 (错误码: {error_code}): {error_msg}" + ) + + return data.get("data", {}).get("items", []) + + +def main(): + """主函数""" + if len(sys.argv) < 2: + print("错误: 缺少飞书多维表格 URL 参数") + print("\n使用方法:") + print(" python scripts/feishu/get_bitable_info.py ") + print("\n示例:") + print(" python scripts/feishu/get_bitable_info.py 'https://xxx.feishu.cn/wiki/xxx?table=xxx'") + sys.exit(1) + + feishu_url = sys.argv[1] + + # 检查环境变量 + app_id = os.getenv("FEISHU_APP_ID") + app_secret = os.getenv("FEISHU_APP_SECRET") + + if not app_id or not app_secret: + print("错误: 缺少飞书 API 凭证") + print("\n请设置环境变量:") + print(" export FEISHU_APP_ID='your_app_id'") + print(" export FEISHU_APP_SECRET='your_app_secret'") + sys.exit(1) + + print("=" * 80) + print("飞书多维表格信息获取工具") + print("=" * 80) + print(f"\n输入 URL: {feishu_url}\n") + + # 解析 URL + print("[1/4] 解析 URL...") + parsed = parse_feishu_url(feishu_url) + + print(f" URL 类型: {parsed['url_type']}") + print(f" Token: {parsed['token']}") + print(f" Table ID: {parsed['table_id']}") + print(f" View ID: {parsed['view_id']}") + + if not parsed['token']: + print("\n错误: 无法从 URL 中解析出 token") + print("请确保 URL 格式正确") + sys.exit(1) + + # 获取访问令牌 + print("\n[2/4] 获取访问令牌...") + try: + token = get_tenant_access_token(app_id, app_secret) + print("✓ 成功获取访问令牌") + except Exception as e: + print(f"✗ 获取访问令牌失败: {e}") + sys.exit(1) + + # 获取实际的 app_token + app_token = None + + if parsed['url_type'] == 'wiki': + print("\n[3/4] 从知识库节点获取多维表格信息...") + try: + node_info = get_wiki_node_info(token, parsed['token']) + obj_type = node_info.get('obj_type') + + if obj_type == 'bitable': + # 知识库中的多维表格 + app_token = node_info.get('obj_token') + print(f"✓ 找到多维表格") + print(f" 节点类型: {obj_type}") + print(f" App Token: {app_token}") + else: + print(f"✗ 该节点不是多维表格,类型为: {obj_type}") + sys.exit(1) + + except Exception as e: + print(f"✗ 获取节点信息失败: {e}") + print("\n提示: 如果是独立的多维表格,请直接访问多维表格页面并复制 URL") + sys.exit(1) + + elif parsed['url_type'] == 'base': + print("\n[3/4] 使用独立多维表格的 app_token...") + app_token = parsed['token'] + print(f"✓ App Token: {app_token}") + + else: + print(f"\n错误: 不支持的 URL 类型: {parsed['url_type']}") + sys.exit(1) + + if not app_token: + print("\n错误: 无法获取 app_token") + sys.exit(1) + + # 列出数据表 + print("\n[4/4] 列出多维表格中的数据表...") + try: + tables = list_tables_in_base(token, app_token) + print(f"✓ 找到 {len(tables)} 个数据表:\n") + + for i, table in enumerate(tables, 1): + table_id = table.get('table_id') + table_name = table.get('name') + is_target = table_id == parsed['table_id'] + marker = " ← 目标表格" if is_target else "" + print(f" {i}. {table_name}") + print(f" Table ID: {table_id}{marker}") + + except Exception as e: + print(f"✗ 获取数据表列表失败: {e}") + sys.exit(1) + + # 输出配置信息 + print("\n" + "=" * 80) + print("配置信息") + print("=" * 80) + print("\n请将以下配置更新到脚本中:\n") + print(f"FEISHU_APP_TOKEN = \"{app_token}\"") + + if parsed['table_id']: + print(f"FEISHU_TABLE_ID = \"{parsed['table_id']}\"") + else: + print(f"# 请从上面的列表中选择一个 table_id") + print(f"FEISHU_TABLE_ID = \"\"") + + if parsed['view_id']: + print(f"FEISHU_VIEW_ID = \"{parsed['view_id']}\" # 可选,建议不使用") + + print("\n" + "=" * 80) + print("完成!") + print("=" * 80) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\n用户中断操作") + sys.exit(1) + except Exception as e: + print(f"\n\n未预期的错误: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/test_scripts/send_feishu_message.py b/test_scripts/send_feishu_message.py new file mode 100755 index 000000000..0849bb3e5 --- /dev/null +++ b/test_scripts/send_feishu_message.py @@ -0,0 +1,413 @@ +#!/usr/bin/env python3 +""" +飞书群消息发送测试脚本 + +支持两种方式: +1. Webhook 方式(推荐,简单) +2. 飞书开放平台 API 方式 + +使用方法: + 方式1 - Webhook: + python send_feishu_message.py --webhook "YOUR_WEBHOOK_URL" --text "测试消息" + + 方式2 - 开放平台 API: + python send_feishu_message.py --app-id "YOUR_APP_ID" --app-secret "YOUR_APP_SECRET" --chat-id "YOUR_CHAT_ID" --text "测试消息" +""" + +import argparse +import base64 +import hashlib +import hmac +import json +import sys +import time +from typing import Optional + +import requests + + +def gen_sign(timestamp: int, secret: str) -> str: + """ + 生成飞书 Webhook 签名 + + Args: + timestamp: 时间戳(秒) + secret: 签名密钥 + + Returns: + str: 生成的签名 + """ + # 拼接 timestamp 和 secret + string_to_sign = '{}\n{}'.format(timestamp, secret) + hmac_code = hmac.new(string_to_sign.encode("utf-8"), digestmod=hashlib.sha256).digest() + + # 对结果进行 base64 处理 + sign = base64.b64encode(hmac_code).decode('utf-8') + + return sign + + +def send_webhook_message( + webhook_url: str, + message: str, + msg_type: str = "text", + secret: Optional[str] = None +) -> bool: + """ + 通过 Webhook 发送消息到飞书群 + + Args: + webhook_url: 飞书群机器人的 Webhook URL + message: 要发送的消息内容 + msg_type: 消息类型,支持 "text", "post", "interactive" + secret: 可选的签名密钥,如果提供则会添加签名验证 + + Returns: + bool: 发送是否成功 + """ + if msg_type == "text": + payload = { + "msg_type": "text", + "content": { + "text": message + } + } + elif msg_type == "post": + # 富文本消息 + payload = { + "msg_type": "post", + "content": { + "post": { + "zh_cn": { + "title": "通知", + "content": [ + [ + { + "tag": "text", + "text": message + } + ] + ] + } + } + } + } + else: + print(f"不支持的消息类型: {msg_type}") + return False + + # 如果提供了 secret,添加签名 + if secret: + timestamp = int(time.time()) + sign = gen_sign(timestamp, secret) + payload["timestamp"] = str(timestamp) + payload["sign"] = sign + print(f"已添加签名验证 (timestamp: {timestamp})") + + try: + response = requests.post( + webhook_url, + json=payload, + headers={"Content-Type": "application/json"}, + timeout=10 + ) + response.raise_for_status() + result = response.json() + + if result.get("code") == 0: + print("✓ 消息发送成功!") + return True + else: + print(f"✗ 消息发送失败: {result}") + return False + except requests.exceptions.RequestException as e: + print(f"✗ 请求失败: {e}") + return False + except Exception as e: + print(f"✗ 发生错误: {e}") + return False + + +def get_tenant_access_token(app_id: str, app_secret: str) -> Optional[str]: + """ + 获取飞书开放平台的 tenant_access_token + + Args: + app_id: 应用的 App ID + app_secret: 应用的 App Secret + + Returns: + str: tenant_access_token,失败返回 None + """ + url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" + payload = { + "app_id": app_id, + "app_secret": app_secret + } + + try: + response = requests.post(url, json=payload, timeout=10) + response.raise_for_status() + result = response.json() + + if result.get("code") == 0: + return result.get("tenant_access_token") + else: + print(f"✗ 获取 token 失败: {result}") + return None + except Exception as e: + print(f"✗ 获取 token 时发生错误: {e}") + return None + + +def send_api_message( + app_id: str, + app_secret: str, + chat_id: str, + message: str, + msg_type: str = "text" +) -> bool: + """ + 通过飞书开放平台 API 发送消息 + + Args: + app_id: 应用的 App ID + app_secret: 应用的 App Secret + chat_id: 群聊的 chat_id + message: 要发送的消息内容 + msg_type: 消息类型,支持 "text", "post" + + Returns: + bool: 发送是否成功 + """ + # 获取 access token + token = get_tenant_access_token(app_id, app_secret) + if not token: + return False + + # 发送消息 + url = "https://open.feishu.cn/open-apis/im/v1/messages" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + if msg_type == "text": + content = {"text": message} + elif msg_type == "post": + content = { + "zh_cn": { + "title": "通知", + "content": [ + [ + { + "tag": "text", + "text": message + } + ] + ] + } + } + else: + print(f"不支持的消息类型: {msg_type}") + return False + + payload = { + "receive_id": chat_id, + "msg_type": msg_type, + "content": json.dumps(content) + } + + params = {"receive_id_type": "chat_id"} + + try: + response = requests.post( + url, + headers=headers, + params=params, + json=payload, + timeout=10 + ) + response.raise_for_status() + result = response.json() + + if result.get("code") == 0: + print("✓ 消息发送成功!") + return True + else: + print(f"✗ 消息发送失败: {result}") + return False + except Exception as e: + print(f"✗ 发送消息时发生错误: {e}") + return False + + +def list_chats(app_id: str, app_secret: str) -> bool: + """ + 列出机器人加入的所有群聊 + + Args: + app_id: 应用的 App ID + app_secret: 应用的 App Secret + + Returns: + bool: 是否成功获取列表 + """ + # 获取 access token + token = get_tenant_access_token(app_id, app_secret) + if not token: + return False + + # 获取群列表 + url = "https://open.feishu.cn/open-apis/im/v1/chats" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + try: + response = requests.get(url, headers=headers, timeout=10) + response.raise_for_status() + result = response.json() + + if result.get("code") == 0: + chats = result.get("data", {}).get("items", []) + if not chats: + print("未找到任何群聊") + return True + + print(f"\n找到 {len(chats)} 个群聊:\n") + for i, chat in enumerate(chats, 1): + chat_id = chat.get("chat_id", "N/A") + name = chat.get("name", "未命名") + description = chat.get("description", "") + print(f"{i}. 群名称: {name}") + print(f" Chat ID: {chat_id}") + if description: + print(f" 描述: {description}") + print() + + return True + else: + print(f"✗ 获取群列表失败: {result}") + return False + except Exception as e: + print(f"✗ 获取群列表时发生错误: {e}") + return False + + +def main(): + parser = argparse.ArgumentParser( + description="飞书群消息发送测试脚本", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + # 方式1: 使用 Webhook(推荐) + python send_feishu_message.py --webhook "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" --text "测试消息" + + # 使用 Webhook + 签名验证 + python send_feishu_message.py --webhook "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" --secret "YOUR_SECRET" --text "测试消息" + + # 方式2: 使用开放平台 API + python send_feishu_message.py --app-id "cli_xxx" --app-secret "xxx" --chat-id "oc_xxx" --text "测试消息" + + # 发送富文本消息 + python send_feishu_message.py --webhook "https://open.feishu.cn/open-apis/bot/v2/hook/xxx" --text "测试消息" --type post + + # 列出所有群聊(获取 Chat ID) + python send_feishu_message.py --app-id "cli_xxx" --app-secret "xxx" --list-chats + """ + ) + + # Webhook 方式参数 + parser.add_argument( + "--webhook", + help="飞书群机器人的 Webhook URL" + ) + parser.add_argument( + "--secret", + help="Webhook 签名密钥(如果机器人配置了签名验证)" + ) + + # 开放平台 API 方式参数 + parser.add_argument( + "--app-id", + help="飞书应用的 App ID" + ) + parser.add_argument( + "--app-secret", + help="飞书应用的 App Secret" + ) + parser.add_argument( + "--chat-id", + help="群聊的 chat_id" + ) + + # 通用参数 + parser.add_argument( + "--text", + help="要发送的消息内容" + ) + parser.add_argument( + "--type", + choices=["text", "post"], + default="text", + help="消息类型:text(纯文本) 或 post(富文本),默认 text" + ) + + # 辅助功能 + parser.add_argument( + "--list-chats", + action="store_true", + help="列出机器人加入的所有群聊(需要 --app-id 和 --app-secret)" + ) + + args = parser.parse_args() + + # 处理列出群聊功能 + if args.list_chats: + if not (args.app_id and args.app_secret): + print("✗ 错误:使用 --list-chats 需要提供 --app-id 和 --app-secret") + sys.exit(1) + + print("正在获取群聊列表...") + success = list_chats(args.app_id, args.app_secret) + sys.exit(0 if success else 1) + + # 验证发送消息的参数 + if not args.text: + print("✗ 错误:发送消息需要提供 --text 参数") + parser.print_help() + sys.exit(1) + + # 验证参数 + if args.webhook: + # Webhook 方式 + print(f"使用 Webhook 方式发送消息...") + print(f"消息内容: {args.text}") + print(f"消息类型: {args.type}") + success = send_webhook_message(args.webhook, args.text, args.type, args.secret) + elif args.app_id and args.app_secret and args.chat_id: + # 开放平台 API 方式 + print(f"使用开放平台 API 方式发送消息...") + print(f"消息内容: {args.text}") + print(f"消息类型: {args.type}") + success = send_api_message( + args.app_id, + args.app_secret, + args.chat_id, + args.text, + args.type + ) + else: + print("✗ 错误:必须提供以下参数之一:") + print(" 1. --webhook (Webhook URL)") + print(" 2. --app-id, --app-secret, --chat-id (开放平台 API)") + parser.print_help() + sys.exit(1) + + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/test_scripts/sync_providers_to_feishu.py b/test_scripts/sync_providers_to_feishu.py new file mode 100755 index 000000000..d3f81e494 --- /dev/null +++ b/test_scripts/sync_providers_to_feishu.py @@ -0,0 +1,728 @@ +#!/usr/bin/env python3 +""" +同步Provider数据到飞书多维表格 + +功能说明: +1. 从数据库读取所有 provider 数据 +2. 将数据同步到飞书多维表格 +3. 如果数据表不存在则创建默认表 +4. 删除数据库中不存在的 provider + +飞书多维表格地址: +https://zxoriiwxds4.feishu.cn/wiki/YhxAwdtCJi9Pe6kTTtEcEFO4nkd?table=tblqCBVS51CyfRyZ&view=vewRlroil6 + +使用方法: +1. 设置环境变量: + export FEISHU_APP_ID="your_app_id" + export FEISHU_APP_SECRET="your_app_secret" + +2. 运行脚本: + python scripts/admin/sync_providers_to_feishu.py + +3. 选项: + --dry-run: 仅查看将要同步的数据,不实际执行 + --create-table: 如果表不存在则创建 + --db-config: 数据库配置名称(默认: search_engine) +""" + +import asyncio +import json +import os +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +import requests +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +# 添加项目根目录到路径 +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +from shared.database import get_async_session +from shared.database.models.core import Provider + +# 飞书多维表格配置 +FEISHU_APP_TOKEN = "YVlHbrgeYa2JxgsUFfAcytzunwf" # 从URL解析出来 +FEISHU_TABLE_ID = "tblqCBVS51CyfRyZ" # 从URL解析出来 +FEISHU_VIEW_ID = "vewRlroil6" # 从URL解析出来(暂未使用,以避免权限问题) +FEISHU_API_BASE = "https://open.feishu.cn/open-apis" + +# 表格字段映射配置(字段名同名,不需要转换) +FIELD_MAPPINGS = { + "provider_id": "provider_id", + "name": "name", + "description": "description", + "website_url": "website_url", + "docs_url": "docs_url", + "categories": "categories", # 特殊处理:从 tags.categories 读取 + "tools": "tools", # 特殊处理:显示 tool 数量 + "created_at": "created_at", + "updated_at": "updated_at", +} + + +class FeishuClient: + """飞书API客户端""" + + def __init__(self, app_id: str, app_secret: str): + self.app_id = app_id + self.app_secret = app_secret + self.tenant_access_token: Optional[str] = None + self.token_expire_time: Optional[datetime] = None + + def _ensure_token(self) -> None: + """确保访问令牌有效""" + if self.tenant_access_token and self.token_expire_time: + if datetime.now() < self.token_expire_time: + return + + # 获取新的访问令牌 + url = f"{FEISHU_API_BASE}/auth/v3/tenant_access_token/internal" + payload = { + "app_id": self.app_id, + "app_secret": self.app_secret, + } + + response = requests.post(url, json=payload, timeout=30) + response.raise_for_status() + + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"获取访问令牌失败: {data.get('msg')}") + + self.tenant_access_token = data["tenant_access_token"] + # Token 有效期通常为 2 小时,这里设置为 1.5 小时后过期 + from datetime import timedelta + self.token_expire_time = datetime.now() + timedelta(seconds=data.get("expire", 7200) - 1800) + + print(f"✓ 获取访问令牌成功,有效期至: {self.token_expire_time.strftime('%Y-%m-%d %H:%M:%S')}") + + def _get_headers(self) -> Dict[str, str]: + """获取请求头""" + self._ensure_token() + return { + "Authorization": f"Bearer {self.tenant_access_token}", + "Content-Type": "application/json; charset=utf-8", + } + + def get_table_info(self, app_token: str, table_id: str) -> Dict[str, Any]: + """获取表格信息""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}" + + response = requests.get(url, headers=self._get_headers(), timeout=30) + response.raise_for_status() + + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"获取表格信息失败: {data.get('msg')}") + + return data.get("data", {}) + + def list_tables(self, app_token: str) -> List[Dict[str, Any]]: + """列出所有表格""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables" + + response = requests.get(url, headers=self._get_headers(), timeout=30) + response.raise_for_status() + + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"获取表格列表失败: {data.get('msg')}") + + return data.get("data", {}).get("items", []) + + def create_table(self, app_token: str, table_name: str, fields: List[Dict[str, Any]]) -> Dict[str, Any]: + """创建新表格""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables" + + payload = { + "table": { + "name": table_name, + "default_view_name": "默认视图", + "fields": fields, + } + } + + response = requests.post(url, headers=self._get_headers(), json=payload, timeout=30) + response.raise_for_status() + + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"创建表格失败: {data.get('msg')}") + + return data.get("data", {}) + + def list_fields(self, app_token: str, table_id: str) -> List[Dict[str, Any]]: + """获取表格字段列表""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/fields" + params = {"page_size": 100} + + response = requests.get(url, headers=self._get_headers(), params=params, timeout=30) + response.raise_for_status() + + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"获取字段列表失败: {data.get('msg')}") + + return data.get("data", {}).get("items", []) + + def list_records( + self, + app_token: str, + table_id: str, + page_size: int = 500, + view_id: Optional[str] = None + ) -> List[Dict[str, Any]]: + """获取表格记录列表""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records" + + all_records = [] + page_token = None + + while True: + params = {"page_size": page_size} + if view_id: + params["view_id"] = view_id + if page_token: + params["page_token"] = page_token + + response = requests.get(url, headers=self._get_headers(), params=params, timeout=30) + + # 先检查响应内容,再判断是否报错 + try: + data = response.json() + except Exception: + # 如果无法解析 JSON,说明返回的不是标准响应 + response.raise_for_status() + raise RuntimeError(f"无法解析飞书 API 响应: {response.text[:200]}") + + # 检查飞书 API 返回的错误码 + if data.get("code") != 0: + error_msg = data.get("msg", "未知错误") + error_code = data.get("code") + raise RuntimeError( + f"获取记录列表失败 (错误码: {error_code}): {error_msg}\n" + f"请求 URL: {url}\n" + f"请求参数: {params}" + ) + + # HTTP 状态码检查 + if response.status_code != 200: + raise RuntimeError( + f"HTTP 请求失败 (状态码: {response.status_code}): {response.text[:200]}\n" + f"请求 URL: {url}\n" + f"请求参数: {params}" + ) + + records = data.get("data", {}).get("items", []) + all_records.extend(records) + + # 检查是否有下一页 + page_token = data.get("data", {}).get("page_token") + has_more = data.get("data", {}).get("has_more", False) + + if not has_more or not page_token: + break + + return all_records + + def create_record(self, app_token: str, table_id: str, fields: Dict[str, Any]) -> Dict[str, Any]: + """创建记录""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records" + + payload = {"fields": fields} + + response = requests.post(url, headers=self._get_headers(), json=payload, timeout=30) + response.raise_for_status() + + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"创建记录失败: {data.get('msg')}") + + return data.get("data", {}) + + def update_record( + self, + app_token: str, + table_id: str, + record_id: str, + fields: Dict[str, Any] + ) -> Dict[str, Any]: + """更新记录""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}" + + payload = {"fields": fields} + + response = requests.put(url, headers=self._get_headers(), json=payload, timeout=30) + response.raise_for_status() + + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"更新记录失败: {data.get('msg')}") + + return data.get("data", {}) + + def delete_record(self, app_token: str, table_id: str, record_id: str) -> None: + """删除记录""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records/{record_id}" + + response = requests.delete(url, headers=self._get_headers(), timeout=30) + response.raise_for_status() + + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"删除记录失败: {data.get('msg')}") + + def batch_create_records( + self, + app_token: str, + table_id: str, + records: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """批量创建记录""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create" + + # 飞书API限制每次最多500条记录 + batch_size = 500 + all_results = [] + + for i in range(0, len(records), batch_size): + batch = records[i:i + batch_size] + payload = {"records": [{"fields": record} for record in batch]} + + response = requests.post(url, headers=self._get_headers(), json=payload, timeout=60) + + # 先解析响应 + try: + data = response.json() + except Exception: + response.raise_for_status() + raise RuntimeError(f"无法解析飞书 API 响应: {response.text[:200]}") + + if data.get("code") != 0: + error_msg = data.get("msg", "未知错误") + error_code = data.get("code") + + # 特殊处理字段不存在的错误 + if "FieldNameNotFound" in error_msg or error_code == 1254208: + # 尝试从第一条记录中获取字段名 + field_names = list(records[0].keys()) if records else [] + raise RuntimeError( + f"批量创建记录失败: 字段名称不存在\n" + f"错误信息: {error_msg}\n" + f"尝试使用的字段: {', '.join(field_names)}\n\n" + "请确保飞书多维表格中已创建以下字段(字段名称必须完全匹配):\n" + + "\n".join([f" - {field}" for field in field_names]) + ) + + # 特殊处理文本字段转换失败的错误 + if "TextFieldConvFail" in error_msg or error_code == 1254060: + # 输出第一条记录的数据用于调试 + first_record = records[0] if records else {} + debug_info = "\n第一条记录的数据类型:\n" + for key, value in first_record.items(): + value_type = type(value).__name__ + value_str = str(value)[:100] # 限制长度 + debug_info += f" {key}: {value_str} (类型: {value_type})\n" + + raise RuntimeError( + f"批量创建记录失败 (错误码: {error_code}): {error_msg}\n\n" + f"这个错误通常是因为字段类型不匹配。{debug_info}\n" + f"请检查飞书表格中的字段类型,所有字段都应该是「单行文本」或「多行文本」类型:\n" + f" • provider_id: 单行文本\n" + f" • name: 单行文本\n" + f" • description: 多行文本\n" + f" • website_url: 单行文本\n" + f" • docs_url: 单行文本\n" + f" • tools: 单行文本(存储数字)\n" + f" • created_at: 单行文本(YYYY-MM-DD HH:MM:SS 格式)\n" + f" • updated_at: 单行文本(YYYY-MM-DD HH:MM:SS 格式)\n\n" + f"完整错误响应:{data}" + ) + + raise RuntimeError( + f"批量创建记录失败 (错误码: {error_code}): {error_msg}" + ) + + results = data.get("data", {}).get("records", []) + all_results.extend(results) + + return all_results + + def batch_delete_records(self, app_token: str, table_id: str, record_ids: List[str]) -> None: + """批量删除记录""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_delete" + + # 飞书API限制每次最多500条记录 + batch_size = 500 + + for i in range(0, len(record_ids), batch_size): + batch = record_ids[i:i + batch_size] + payload = {"records": batch} + + response = requests.post(url, headers=self._get_headers(), json=payload, timeout=60) + response.raise_for_status() + + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"批量删除记录失败: {data.get('msg')}") + + +def extract_text(jsonb_field: Optional[Dict[str, Any]], lang: str = "en") -> str: + """从多语言JSONB字段中提取文本""" + if not jsonb_field or not isinstance(jsonb_field, dict): + return "" + return jsonb_field.get(lang, jsonb_field.get("en", "")) + + +def format_list(items: Optional[List[str]]) -> str: + """格式化列表为字符串""" + if not items: + return "" + return ", ".join(items) + + +def format_status(status: Optional[int]) -> str: + """格式化状态码为可读文本""" + if status is None: + return "未知" + + status_map = { + 0: "初始", + 1: "已检查", + 100: "暂停", + -1: "错误", + -2: "认证失败", + -3: "缺少必要参数", + -4: "响应超时", + -5: "网络请求失败", + } + + return status_map.get(status, f"HTTP {status}") + + +async def query_all_providers(session: AsyncSession) -> List[Provider]: + """查询所有providers,按provider_id字母升序排列,并预加载 provider_tools 关系""" + stmt = ( + select(Provider) + .options(selectinload(Provider.provider_tools)) # 预加载 tools 关系 + .order_by(Provider.provider_id.asc()) + ) + result = await session.execute(stmt) + return list(result.scalars().all()) + + +def convert_provider_to_feishu_record(provider: Provider) -> Dict[str, Any]: + """将Provider对象转换为飞书记录格式""" + # 处理 docs_url(可能是数组) + docs_url_str = "" + if provider.docs_url: + # 确保是列表类型 + if isinstance(provider.docs_url, list): + # 检查是否是字符数组(错误的存储格式) + # 如果列表的第一个元素长度为1,很可能是字符数组 + if provider.docs_url and len(provider.docs_url) > 0 and len(str(provider.docs_url[0])) == 1: + # 这是字符数组,需要重新组合成字符串 + docs_url_str = "".join(str(char) for char in provider.docs_url) + else: + # 正常的字符串数组 + valid_urls = [str(url) for url in provider.docs_url if url] + docs_url_str = ", ".join(valid_urls) + elif isinstance(provider.docs_url, str): + # 如果是字符串,直接使用 + docs_url_str = provider.docs_url + else: + # 其他情况转为字符串 + docs_url_str = str(provider.docs_url) + + # 处理 categories(从 tags.categories 读取) + categories_str = "" + if provider.tags and isinstance(provider.tags, dict): + categories = provider.tags.get("categories", []) + if isinstance(categories, list): + # 过滤掉 None 和空字符串,转为逗号分隔的字符串 + valid_cats = [str(cat) for cat in categories if cat] + categories_str = ", ".join(valid_cats) + + # 计算 tools 数量 - 使用 provider_tools 关系(实际的 Tool 对象) + tools_count = 0 + if hasattr(provider, 'provider_tools') and provider.provider_tools: + # 使用关系中的 Tool 对象数量 + tools_count = len(provider.provider_tools) + + # 确保所有文本字段都是字符串类型 + provider_id_str = str(provider.provider_id) if provider.provider_id else "" + name_str = extract_text(provider.name) + if not name_str: # 如果提取不到英文名称,使用 provider_id + name_str = provider_id_str + + description_str = extract_text(provider.description) + website_url_str = str(provider.website_url) if provider.website_url else "" + + # 日期字段:如果飞书字段类型是"文本",使用字符串格式;如果是"日期",使用时间戳 + # 这里使用字符串格式,更通用 + created_at_str = provider.created_at.strftime("%Y-%m-%d %H:%M:%S") if provider.created_at else "" + updated_at_str = provider.updated_at.strftime("%Y-%m-%d %H:%M:%S") if provider.updated_at else "" + + # 构建记录字典,确保所有值的类型正确 + record = { + "provider_id": provider_id_str, + "name": name_str, + "description": description_str, + "website_url": website_url_str, + "docs_url": docs_url_str, + "categories": categories_str, + "tools": str(tools_count), # 转为字符串,避免类型不匹配 + "created_at": created_at_str, + "updated_at": updated_at_str, + } + + return record + + +def ensure_table_fields(client: FeishuClient, app_token: str, table_id: str, strict: bool = False) -> Dict[str, str]: + """确保表格包含所需字段,返回字段名到字段ID的映射 + + Args: + client: 飞书客户端 + app_token: 应用 token + table_id: 表格 ID + strict: 是否严格模式(缺少字段时抛出异常) + + Returns: + 字段名到字段ID的映射 + + Raises: + RuntimeError: 当 strict=True 且存在缺失字段时 + """ + existing_fields = client.list_fields(app_token, table_id) + + field_name_to_id = {} + for field in existing_fields: + field_name = field.get("field_name") + field_id = field.get("field_id") + if field_name and field_id: + field_name_to_id[field_name] = field_id + + # 检查必需的字段是否存在 + required_fields = list(FIELD_MAPPINGS.values()) + missing_fields = [f for f in required_fields if f not in field_name_to_id] + + if missing_fields: + error_msg = f"表格缺少以下字段: {', '.join(missing_fields)}" + if strict: + raise RuntimeError( + f"{error_msg}\n\n" + "请在飞书多维表格中手动添加这些字段:\n" + + "\n".join([f" - {field}" for field in missing_fields]) + + "\n\n字段类型建议(所有字段都使用文本类型):\n" + " provider_id: 单行文本\n" + " name: 单行文本\n" + " description: 多行文本\n" + " website_url: 单行文本\n" + " docs_url: 单行文本\n" + " tools: 单行文本(存储数字)\n" + " created_at: 单行文本(YYYY-MM-DD HH:MM:SS 格式)\n" + " updated_at: 单行文本(YYYY-MM-DD HH:MM:SS 格式)" + ) + else: + print(f"⚠ 警告: {error_msg}") + print(" 请手动在飞书多维表格中添加这些字段") + + return field_name_to_id + + +async def sync_providers_to_feishu( + db_config: str = "search_engine", + dry_run: bool = False, + create_table: bool = False, +) -> None: + """同步providers到飞书多维表格""" + + # 获取飞书API凭证 + app_id = os.getenv("FEISHU_APP_ID") + app_secret = os.getenv("FEISHU_APP_SECRET") + + if not app_id or not app_secret: + raise RuntimeError( + "缺少飞书API凭证,请设置环境变量:\n" + " export FEISHU_APP_ID='your_app_id'\n" + " export FEISHU_APP_SECRET='your_app_secret'" + ) + + # 初始化飞书客户端 + client = FeishuClient(app_id, app_secret) + + print("=" * 80) + print("开始同步 Provider 数据到飞书多维表格") + print("=" * 80) + print(f"数据库配置: {db_config}") + print(f"飞书应用: {FEISHU_APP_TOKEN}") + print(f"表格ID: {FEISHU_TABLE_ID}") + print(f"Dry Run: {dry_run}") + print("=" * 80) + + # 查询数据库中的providers + print("\n[1/5] 查询数据库中的 Provider 数据...") + async for session in get_async_session(db_config): + try: + providers = await query_all_providers(session) + print(f"✓ 找到 {len(providers)} 个 providers") + + if not providers: + print("没有找到 providers 数据,退出") + return + + # 构建 provider_id 到 provider 的映射 + db_provider_ids = {p.provider_id for p in providers} + db_providers_map = {p.provider_id: p for p in providers} + + except Exception as e: + print(f"✗ 查询数据库失败: {e}") + raise + finally: + break + + # 验证表格字段 + print("\n[2/5] 验证飞书多维表格字段...") + try: + field_mapping = ensure_table_fields(client, FEISHU_APP_TOKEN, FEISHU_TABLE_ID, strict=True) + print(f"✓ 所有必需字段都存在") + except Exception as e: + print(f"✗ 字段验证失败: {e}") + raise + + # 获取飞书表格中的现有记录 + print("\n[3/4] 获取飞书多维表格中的现有记录...") + try: + # 注意:不使用 view_id 参数,因为某些情况下会导致 400 错误 + # 如果需要指定视图,请确保 view_id 正确且有权限访问 + feishu_records = client.list_records(FEISHU_APP_TOKEN, FEISHU_TABLE_ID) + print(f"✓ 找到 {len(feishu_records)} 条飞书记录") + + # 收集所有记录ID用于删除 + feishu_record_ids = [record.get("record_id") for record in feishu_records if record.get("record_id")] + + except Exception as e: + print(f"✗ 获取飞书记录失败: {e}") + raise + + # 准备要写入的所有记录(已按provider_id字母升序排列) + print("\n[4/4] 准备同步数据...") + records_to_sync = [] + for provider in providers: + record = convert_provider_to_feishu_record(provider) + records_to_sync.append(record) + + print(f" - 飞书现有记录: {len(feishu_record_ids)} 条") + print(f" - 数据库记录: {len(records_to_sync)} 条(按provider_id升序)") + + if dry_run: + print("\n[Dry Run] 以下是将要执行的操作(不会实际执行):") + print(f"\n步骤 1: 删除飞书表格中的所有 {len(feishu_record_ids)} 条记录") + print(f"步骤 2: 按provider_id升序写入 {len(records_to_sync)} 条记录") + + print("\n前10条记录(按provider_id升序):") + for i, provider in enumerate(providers[:10]): + print(f" {i+1}. {provider.provider_id}: {extract_text(provider.name)}") + if len(providers) > 10: + print(f" ... 还有 {len(providers) - 10} 条") + + print("\n[Dry Run] 结束,未执行实际操作") + return + + # 步骤1: 删除飞书表格中的所有记录 + if feishu_record_ids: + print(f"\n清空飞书表格,删除 {len(feishu_record_ids)} 条记录...") + try: + client.batch_delete_records(FEISHU_APP_TOKEN, FEISHU_TABLE_ID, feishu_record_ids) + print(f"✓ 成功删除所有记录") + except Exception as e: + print(f"✗ 删除记录失败: {e}") + raise + else: + print(f"\n飞书表格为空,无需清空") + + # 步骤2: 按顺序写入所有记录 + print(f"\n写入 {len(records_to_sync)} 条记录(按provider_id升序)...") + try: + created_records = client.batch_create_records( + FEISHU_APP_TOKEN, + FEISHU_TABLE_ID, + records_to_sync + ) + print(f"✓ 成功写入 {len(created_records)} 条记录") + + except Exception as e: + print(f"✗ 写入记录失败: {e}") + raise + + print("\n" + "=" * 80) + print("同步完成!") + print("=" * 80) + print(f"总计: 删除 {len(feishu_record_ids)} 条 | 写入 {len(created_records)} 条(按provider_id升序)") + print("=" * 80) + + +async def main(): + """主函数""" + import argparse + + parser = argparse.ArgumentParser( + description="同步 Provider 数据到飞书多维表格", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + # 查看将要同步的数据(不实际执行) + python scripts/admin/sync_providers_to_feishu.py --dry-run + + # 执行同步 + python scripts/admin/sync_providers_to_feishu.py + + # 使用不同的数据库配置 + python scripts/admin/sync_providers_to_feishu.py --db-config default + """ + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="仅查看将要同步的数据,不实际执行" + ) + + parser.add_argument( + "--create-table", + action="store_true", + help="如果表不存在则创建(暂未实现)" + ) + + parser.add_argument( + "--db-config", + default="search_engine", + help="数据库配置名称(默认: search_engine)" + ) + + args = parser.parse_args() + + try: + await sync_providers_to_feishu( + db_config=args.db_config, + dry_run=args.dry_run, + create_table=args.create_table, + ) + except KeyboardInterrupt: + print("\n\n用户中断操作") + sys.exit(1) + except Exception as e: + print(f"\n\n执行失败: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/test_scripts/test_feishu_connection.py b/test_scripts/test_feishu_connection.py new file mode 100755 index 000000000..d275a3c15 --- /dev/null +++ b/test_scripts/test_feishu_connection.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +""" +测试飞书 API 连接 + +这个脚本用于测试飞书 API 配置是否正确,包括: +1. 验证 App ID 和 App Secret +2. 获取访问令牌 +3. 访问目标多维表格 +4. 列出表格字段 + +使用方法: + export FEISHU_APP_ID="cli_xxxxxxxxxxxxxxxx" + export FEISHU_APP_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxx" + python scripts/admin/test_feishu_connection.py +""" + +import os +import sys +from pathlib import Path + +import requests + +# 飞书多维表格配置 +FEISHU_APP_TOKEN = "YVlHbrgeYa2JxgsUFfAcytzunwf" +FEISHU_TABLE_ID = "tblqCBVS51CyfRyZ" +FEISHU_API_BASE = "https://open.feishu.cn/open-apis" + + +def print_separator(char="=", length=80): + """打印分隔符""" + print(char * length) + + +def print_step(step_num, total_steps, description): + """打印步骤信息""" + print(f"\n[{step_num}/{total_steps}] {description}") + + +def print_success(message): + """打印成功信息""" + print(f"✓ {message}") + + +def print_error(message): + """打印错误信息""" + print(f"✗ {message}") + + +def print_info(message, indent=0): + """打印信息""" + prefix = " " * indent + print(f"{prefix}{message}") + + +def get_tenant_access_token(app_id: str, app_secret: str) -> str: + """获取租户访问令牌""" + url = f"{FEISHU_API_BASE}/auth/v3/tenant_access_token/internal" + payload = { + "app_id": app_id, + "app_secret": app_secret, + } + + response = requests.post(url, json=payload, timeout=30) + response.raise_for_status() + + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"获取访问令牌失败: {data.get('msg')}") + + return data["tenant_access_token"] + + +def get_table_info(token: str, app_token: str, table_id: str): + """获取表格信息""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json; charset=utf-8", + } + + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"获取表格信息失败: {data.get('msg')}") + + return data.get("data", {}) + + +def list_fields(token: str, app_token: str, table_id: str): + """列出表格字段""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/fields" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json; charset=utf-8", + } + params = {"page_size": 100} + + response = requests.get(url, headers=headers, params=params, timeout=30) + response.raise_for_status() + + data = response.json() + if data.get("code") != 0: + raise RuntimeError(f"获取字段列表失败: {data.get('msg')}") + + return data.get("data", {}).get("items", []) + + +def list_records(token: str, app_token: str, table_id: str, page_size: int = 10): + """列出表格记录(只获取前几条用于测试)""" + url = f"{FEISHU_API_BASE}/bitable/v1/apps/{app_token}/tables/{table_id}/records" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json; charset=utf-8", + } + params = {"page_size": page_size} + + response = requests.get(url, headers=headers, params=params, timeout=30) + + # 先检查响应内容 + try: + data = response.json() + except Exception: + response.raise_for_status() + raise RuntimeError(f"无法解析飞书 API 响应: {response.text[:200]}") + + # 检查飞书 API 返回的错误码 + if data.get("code") != 0: + error_msg = data.get("msg", "未知错误") + error_code = data.get("code") + raise RuntimeError( + f"获取记录列表失败 (错误码: {error_code}): {error_msg}\n" + f"请求 URL: {url}\n" + f"请求参数: {params}" + ) + + # HTTP 状态码检查 + if response.status_code != 200: + raise RuntimeError( + f"HTTP 请求失败 (状态码: {response.status_code}): {response.text[:200]}\n" + f"请求 URL: {url}\n" + f"请求参数: {params}" + ) + + return data.get("data", {}).get("items", []) + + +def main(): + """主函数""" + print_separator() + print("飞书 API 连接测试") + print_separator() + + # 检查环境变量 + print_step(1, 5, "检查环境变量配置") + + app_id = os.getenv("FEISHU_APP_ID") + app_secret = os.getenv("FEISHU_APP_SECRET") + + if not app_id: + print_error("缺少环境变量 FEISHU_APP_ID") + print_info("请设置: export FEISHU_APP_ID='your_app_id'", indent=1) + sys.exit(1) + + if not app_secret: + print_error("缺少环境变量 FEISHU_APP_SECRET") + print_info("请设置: export FEISHU_APP_SECRET='your_app_secret'", indent=1) + sys.exit(1) + + print_success("环境变量配置正确") + print_info(f"App ID: {app_id[:20]}...", indent=1) + print_info(f"App Secret: {'*' * 20}...", indent=1) + + # 获取访问令牌 + print_step(2, 5, "获取访问令牌") + + try: + token = get_tenant_access_token(app_id, app_secret) + print_success("成功获取访问令牌") + print_info(f"Token: {token[:20]}...", indent=1) + except Exception as e: + print_error(f"获取访问令牌失败: {e}") + print_info("可能的原因:", indent=1) + print_info("1. App ID 或 App Secret 错误", indent=2) + print_info("2. 应用未发布", indent=2) + print_info("3. 网络连接问题", indent=2) + sys.exit(1) + + # 获取表格信息 + print_step(3, 5, "获取表格信息") + + try: + table_info = get_table_info(token, FEISHU_APP_TOKEN, FEISHU_TABLE_ID) + print_success("成功获取表格信息") + print_info(f"表格名称: {table_info.get('name', 'N/A')}", indent=1) + print_info(f"表格ID: {table_info.get('table_id', 'N/A')}", indent=1) + print_info(f"修订版本: {table_info.get('revision', 'N/A')}", indent=1) + except Exception as e: + print_error(f"获取表格信息失败: {e}") + print_info("可能的原因:", indent=1) + print_info("1. 应用未添加到多维表格", indent=2) + print_info("2. 缺少 bitable:app 权限", indent=2) + print_info("3. 表格ID或应用Token错误", indent=2) + sys.exit(1) + + # 列出表格字段 + print_step(4, 5, "列出表格字段并验证") + + try: + fields = list_fields(token, FEISHU_APP_TOKEN, FEISHU_TABLE_ID) + print_success(f"成功获取 {len(fields)} 个字段") + + # 期望的字段 + expected_fields = [ + "provider_id", + "name", + "description", + "website_url", + "docs_url", + "categories", + "tools", + "created_at", + "updated_at", + ] + + # 检查字段是否存在 + existing_field_names = [f.get("field_name") for f in fields] + + print_info("当前字段列表:", indent=1) + for field in fields: + field_name = field.get("field_name") + field_type = field.get("type") + is_required = field_name in expected_fields + marker = " ← 必需" if is_required else "" + print_info(f"- {field_name} ({field_type}){marker}", indent=2) + + print_info("\n检查必需字段:", indent=1) + missing_fields = [] + for expected_field in expected_fields: + if expected_field in existing_field_names: + print_info(f"✓ {expected_field}", indent=2) + else: + print_info(f"✗ {expected_field} (缺失)", indent=2) + missing_fields.append(expected_field) + + if missing_fields: + print_error(f"\n缺少 {len(missing_fields)} 个必需字段!") + print_info("\n需要添加的字段:", indent=1) + for field in missing_fields: + print_info(f"- {field}", indent=2) + + print_info("\n建议的字段类型:", indent=1) + print_info("provider_id: 单行文本", indent=2) + print_info("name: 单行文本", indent=2) + print_info("description: 多行文本", indent=2) + print_info("website_url: 单行文本或URL", indent=2) + print_info("docs_url: 单行文本", indent=2) + print_info("categories: 单行文本", indent=2) + print_info("tools: 单行文本(显示数字)", indent=2) + print_info("created_at: 单行文本(格式: YYYY-MM-DD HH:MM:SS)", indent=2) + print_info("updated_at: 单行文本(格式: YYYY-MM-DD HH:MM:SS)", indent=2) + + print_info("\n⚠ 请在飞书多维表格中手动添加缺失的字段后再运行同步脚本", indent=1) + else: + print_success("所有必需字段都存在!") + + except Exception as e: + print_error(f"获取字段列表失败: {e}") + sys.exit(1) + + # 获取记录样例 + print_step(5, 5, "获取记录样例(前10条)") + + try: + records = list_records(token, FEISHU_APP_TOKEN, FEISHU_TABLE_ID, page_size=10) + print_success(f"成功获取 {len(records)} 条记录") + + if records: + print_info("记录样例:", indent=1) + for i, record in enumerate(records[:3], 1): + fields = record.get("fields", {}) + provider_id = fields.get("Provider ID", "N/A") + provider_name = fields.get("Provider Name", "N/A") + print_info(f"{i}. {provider_id}: {provider_name}", indent=2) + + if len(records) > 3: + print_info(f"... 还有 {len(records) - 3} 条记录", indent=2) + else: + print_info("表格中暂无记录", indent=1) + + except Exception as e: + print_error(f"获取记录列表失败: {e}") + sys.exit(1) + + # 测试完成 + print("\n") + print_separator() + print("✓ 所有测试通过!") + print_separator() + print("\n下一步:") + print(" 1. 如果有缺失的字段,请在飞书多维表格中添加") + print(" 2. 运行同步脚本进行 Dry Run:") + print(" python scripts/admin/sync_providers_to_feishu.py --dry-run") + print(" 3. 确认无误后执行实际同步:") + print(" python scripts/admin/sync_providers_to_feishu.py") + print_separator() + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\n用户中断操作") + sys.exit(1) + except Exception as e: + print(f"\n\n未预期的错误: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/test_scripts/test_feishu_websocket.ts b/test_scripts/test_feishu_websocket.ts new file mode 100644 index 000000000..cbbd9d30f --- /dev/null +++ b/test_scripts/test_feishu_websocket.ts @@ -0,0 +1,64 @@ +/** + * Test script for Feishu WebSocket connection + * Run with: bun test_scripts/test_feishu_websocket.ts + */ + +import { loadConfig } from "../src/config/config.js"; +import { monitorFeishuProvider } from "../src/feishu/monitor.js"; + +async function main() { + console.log("Testing Feishu WebSocket connection...\n"); + + const cfg = loadConfig(); + + const abortController = new AbortController(); + + // Stop after 30 seconds for testing + setTimeout(() => { + console.log("\n[Test] 30 seconds elapsed, stopping..."); + abortController.abort(); + }, 30_000); + + // Handle Ctrl+C + process.on("SIGINT", () => { + console.log("\n[Test] Received SIGINT, stopping..."); + abortController.abort(); + }); + + try { + await monitorFeishuProvider({ + config: cfg, + abortSignal: abortController.signal, + eventMode: "websocket", + runtime: { + log: (msg) => console.log(`[LOG] ${msg}`), + error: (msg) => console.error(`[ERR] ${msg}`), + exit: (code) => process.exit(code), + }, + onMessage: async (ctx) => { + console.log("\n[Message Received]"); + console.log(` From: ${ctx.senderId} (${ctx.senderType})`); + console.log(` Chat: ${ctx.chatId} (${ctx.chatType})`); + console.log(` Text: ${ctx.text}`); + console.log(` Mentioned: ${ctx.wasMentioned}`); + + // Auto-reply with echo + try { + await ctx.reply(`Echo: ${ctx.text}`); + console.log(" [Reply sent]"); + } catch (err) { + console.error(` [Reply failed]: ${err}`); + } + }, + }); + } catch (err) { + if (abortController.signal.aborted) { + console.log("[Test] Stopped gracefully."); + } else { + console.error("[Test] Error:", err); + process.exit(1); + } + } +} + +main();