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();