Merge branch 'main' of github.com:QVerisAI/QVerisBot
This commit is contained in:
commit
4b6e0eeebe
15
dist/control-ui/index.html
vendored
Normal file
15
dist/control-ui/index.html
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Clawdbot Control</title>
|
||||||
|
<meta name="color-scheme" content="dark light" />
|
||||||
|
<link rel="icon" href="./favicon.ico" sizes="any" />
|
||||||
|
<script type="module" crossorigin src="./assets/index-BCK1V83p.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="./assets/index-CjW_qQ45.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<clawdbot-app></clawdbot-app>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
extensions/feishu/clawdbot.plugin.json
Normal file
11
extensions/feishu/clawdbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "feishu",
|
||||||
|
"channels": [
|
||||||
|
"feishu"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
extensions/feishu/index.ts
Normal file
16
extensions/feishu/index.ts
Normal file
@ -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;
|
||||||
565
extensions/feishu/package-lock.json
generated
Normal file
565
extensions/feishu/package-lock.json
generated
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
extensions/feishu/package.json
Normal file
14
extensions/feishu/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
179
extensions/feishu/src/channel.ts
Normal file
179
extensions/feishu/src/channel.ts
Normal file
@ -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<ResolvedFeishuAccount> = {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -161,6 +161,7 @@
|
|||||||
"@grammyjs/runner": "^2.0.3",
|
"@grammyjs/runner": "^2.0.3",
|
||||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||||
"@homebridge/ciao": "^1.3.4",
|
"@homebridge/ciao": "^1.3.4",
|
||||||
|
"@larksuiteoapi/node-sdk": "^1.56.1",
|
||||||
"@line/bot-sdk": "^10.6.0",
|
"@line/bot-sdk": "^10.6.0",
|
||||||
"@lydell/node-pty": "1.2.0-beta.3",
|
"@lydell/node-pty": "1.2.0-beta.3",
|
||||||
"@mariozechner/pi-agent-core": "0.49.3",
|
"@mariozechner/pi-agent-core": "0.49.3",
|
||||||
|
|||||||
51
pnpm-lock.yaml
generated
51
pnpm-lock.yaml
generated
@ -34,6 +34,9 @@ importers:
|
|||||||
'@homebridge/ciao':
|
'@homebridge/ciao':
|
||||||
specifier: ^1.3.4
|
specifier: ^1.3.4
|
||||||
version: 1.3.4
|
version: 1.3.4
|
||||||
|
'@larksuiteoapi/node-sdk':
|
||||||
|
specifier: ^1.56.1
|
||||||
|
version: 1.56.1
|
||||||
'@line/bot-sdk':
|
'@line/bot-sdk':
|
||||||
specifier: ^10.6.0
|
specifier: ^10.6.0
|
||||||
version: 10.6.0
|
version: 10.6.0
|
||||||
@ -304,6 +307,12 @@ importers:
|
|||||||
|
|
||||||
extensions/discord: {}
|
extensions/discord: {}
|
||||||
|
|
||||||
|
extensions/feishu:
|
||||||
|
dependencies:
|
||||||
|
'@larksuiteoapi/node-sdk':
|
||||||
|
specifier: ^1.56.1
|
||||||
|
version: 1.56.1
|
||||||
|
|
||||||
extensions/google-antigravity-auth: {}
|
extensions/google-antigravity-auth: {}
|
||||||
|
|
||||||
extensions/google-gemini-cli-auth: {}
|
extensions/google-gemini-cli-auth: {}
|
||||||
@ -1327,6 +1336,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
apache-arrow: '>=15.0.0 <=18.1.0'
|
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':
|
'@line/bot-sdk@10.6.0':
|
||||||
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
|
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@ -3071,6 +3083,9 @@ packages:
|
|||||||
aws4@1.13.2:
|
aws4@1.13.2:
|
||||||
resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
|
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:
|
axios@1.13.2:
|
||||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||||
|
|
||||||
@ -4165,6 +4180,9 @@ packages:
|
|||||||
lodash.debounce@4.0.8:
|
lodash.debounce@4.0.8:
|
||||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||||
|
|
||||||
|
lodash.identity@3.0.0:
|
||||||
|
resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==}
|
||||||
|
|
||||||
lodash.includes@4.3.0:
|
lodash.includes@4.3.0:
|
||||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
@ -4183,9 +4201,15 @@ packages:
|
|||||||
lodash.isstring@4.0.1:
|
lodash.isstring@4.0.1:
|
||||||
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
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:
|
lodash.once@4.1.1:
|
||||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||||
|
|
||||||
|
lodash.pickby@4.6.0:
|
||||||
|
resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==}
|
||||||
|
|
||||||
lodash@4.17.23:
|
lodash@4.17.23:
|
||||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||||
|
|
||||||
@ -6882,6 +6906,20 @@ snapshots:
|
|||||||
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
||||||
'@lancedb/lancedb-win32-x64-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':
|
'@line/bot-sdk@10.6.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.10.9
|
'@types/node': 24.10.9
|
||||||
@ -8929,6 +8967,13 @@ snapshots:
|
|||||||
|
|
||||||
aws4@1.13.2: {}
|
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):
|
axios@1.13.2(debug@4.4.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.15.11(debug@4.4.3)
|
follow-redirects: 1.15.11(debug@4.4.3)
|
||||||
@ -10251,6 +10296,8 @@ snapshots:
|
|||||||
lodash.debounce@4.0.8:
|
lodash.debounce@4.0.8:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
lodash.identity@3.0.0: {}
|
||||||
|
|
||||||
lodash.includes@4.3.0: {}
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
lodash.isboolean@3.0.3: {}
|
lodash.isboolean@3.0.3: {}
|
||||||
@ -10263,8 +10310,12 @@ snapshots:
|
|||||||
|
|
||||||
lodash.isstring@4.0.1: {}
|
lodash.isstring@4.0.1: {}
|
||||||
|
|
||||||
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
lodash.once@4.1.1: {}
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
|
lodash.pickby@4.6.0: {}
|
||||||
|
|
||||||
lodash@4.17.23: {}
|
lodash@4.17.23: {}
|
||||||
|
|
||||||
log-symbols@6.0.0:
|
log-symbols@6.0.0:
|
||||||
|
|||||||
62
src/agents/llm-proxy.ts
Normal file
62
src/agents/llm-proxy.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@ import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
|
|||||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
||||||
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
||||||
import { createTtsTool } from "./tools/tts-tool.js";
|
import { createTtsTool } from "./tools/tts-tool.js";
|
||||||
|
import { createQverisTools } from "./tools/qveris-tools.js";
|
||||||
|
|
||||||
export function createMoltbotTools(options?: {
|
export function createMoltbotTools(options?: {
|
||||||
sandboxBrowserBridgeUrl?: string;
|
sandboxBrowserBridgeUrl?: string;
|
||||||
@ -70,6 +71,11 @@ export function createMoltbotTools(options?: {
|
|||||||
config: options?.config,
|
config: options?.config,
|
||||||
sandboxed: options?.sandboxed,
|
sandboxed: options?.sandboxed,
|
||||||
});
|
});
|
||||||
|
const qverisTools = createQverisTools({
|
||||||
|
config: options?.config,
|
||||||
|
sandboxed: options?.sandboxed,
|
||||||
|
agentSessionKey: options?.agentSessionKey,
|
||||||
|
});
|
||||||
const tools: AnyAgentTool[] = [
|
const tools: AnyAgentTool[] = [
|
||||||
createBrowserTool({
|
createBrowserTool({
|
||||||
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
|
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
|
||||||
@ -137,6 +143,7 @@ export function createMoltbotTools(options?: {
|
|||||||
...(webSearchTool ? [webSearchTool] : []),
|
...(webSearchTool ? [webSearchTool] : []),
|
||||||
...(webFetchTool ? [webFetchTool] : []),
|
...(webFetchTool ? [webFetchTool] : []),
|
||||||
...(imageTool ? [imageTool] : []),
|
...(imageTool ? [imageTool] : []),
|
||||||
|
...qverisTools,
|
||||||
];
|
];
|
||||||
|
|
||||||
const pluginTools = resolvePluginTools({
|
const pluginTools = resolvePluginTools({
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
|||||||
import { resolveUserPath } from "../../utils.js";
|
import { resolveUserPath } from "../../utils.js";
|
||||||
import { resolveMoltbotAgentDir } from "../agent-paths.js";
|
import { resolveMoltbotAgentDir } from "../agent-paths.js";
|
||||||
import { resolveSessionAgentIds } from "../agent-scope.js";
|
import { resolveSessionAgentIds } from "../agent-scope.js";
|
||||||
|
import { setupLlmProxy } from "../llm-proxy.js";
|
||||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
|
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
|
||||||
import { resolveMoltbotDocsPath } from "../docs-path.js";
|
import { resolveMoltbotDocsPath } from "../docs-path.js";
|
||||||
import type { ExecElevatedDefaults } from "../bash-tools.js";
|
import type { ExecElevatedDefaults } from "../bash-tools.js";
|
||||||
@ -109,6 +110,9 @@ export type CompactEmbeddedPiSessionParams = {
|
|||||||
export async function compactEmbeddedPiSessionDirect(
|
export async function compactEmbeddedPiSessionDirect(
|
||||||
params: CompactEmbeddedPiSessionParams,
|
params: CompactEmbeddedPiSessionParams,
|
||||||
): Promise<EmbeddedPiCompactResult> {
|
): Promise<EmbeddedPiCompactResult> {
|
||||||
|
// Set up LLM proxy if configured (must be done before any fetch calls)
|
||||||
|
setupLlmProxy(params.config);
|
||||||
|
|
||||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||||
const prevCwd = process.cwd();
|
const prevCwd = process.cwd();
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
|||||||
import { resolveUserPath } from "../../../utils.js";
|
import { resolveUserPath } from "../../../utils.js";
|
||||||
import { createCacheTrace } from "../../cache-trace.js";
|
import { createCacheTrace } from "../../cache-trace.js";
|
||||||
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
|
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
|
||||||
|
import { setupLlmProxy } from "../../llm-proxy.js";
|
||||||
import { resolveMoltbotAgentDir } from "../../agent-paths.js";
|
import { resolveMoltbotAgentDir } from "../../agent-paths.js";
|
||||||
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
||||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
||||||
@ -133,6 +134,9 @@ export function injectHistoryImagesIntoMessages(
|
|||||||
export async function runEmbeddedAttempt(
|
export async function runEmbeddedAttempt(
|
||||||
params: EmbeddedRunAttemptParams,
|
params: EmbeddedRunAttemptParams,
|
||||||
): Promise<EmbeddedRunAttemptResult> {
|
): Promise<EmbeddedRunAttemptResult> {
|
||||||
|
// Set up LLM proxy if configured (must be done before any fetch calls)
|
||||||
|
setupLlmProxy(params.config);
|
||||||
|
|
||||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||||
const prevCwd = process.cwd();
|
const prevCwd = process.cwd();
|
||||||
const runAbortController = new AbortController();
|
const runAbortController = new AbortController();
|
||||||
|
|||||||
@ -14,6 +14,8 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
|||||||
// NOTE: Keep canonical (lowercase) tool names here.
|
// NOTE: Keep canonical (lowercase) tool names here.
|
||||||
"group:memory": ["memory_search", "memory_get"],
|
"group:memory": ["memory_search", "memory_get"],
|
||||||
"group:web": ["web_search", "web_fetch"],
|
"group:web": ["web_search", "web_fetch"],
|
||||||
|
// QVeris dynamic tool discovery and execution
|
||||||
|
"group:qveris": ["qveris_search", "qveris_execute"],
|
||||||
// Basic workspace/file tools
|
// Basic workspace/file tools
|
||||||
"group:fs": ["read", "write", "edit", "apply_patch"],
|
"group:fs": ["read", "write", "edit", "apply_patch"],
|
||||||
// Host/runtime execution tools
|
// Host/runtime execution tools
|
||||||
@ -53,6 +55,8 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
|||||||
"web_search",
|
"web_search",
|
||||||
"web_fetch",
|
"web_fetch",
|
||||||
"image",
|
"image",
|
||||||
|
"qveris_search",
|
||||||
|
"qveris_execute",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -61,7 +65,7 @@ const TOOL_PROFILES: Record<ToolProfileId, ToolProfilePolicy> = {
|
|||||||
allow: ["session_status"],
|
allow: ["session_status"],
|
||||||
},
|
},
|
||||||
coding: {
|
coding: {
|
||||||
allow: ["group:fs", "group:runtime", "group:sessions", "group:memory", "image"],
|
allow: ["group:fs", "group:runtime", "group:sessions", "group:memory", "image", "group:qveris"],
|
||||||
},
|
},
|
||||||
messaging: {
|
messaging: {
|
||||||
allow: [
|
allow: [
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { Type } from "@sinclair/typebox";
|
|||||||
|
|
||||||
import type { MoltbotConfig } from "../../config/config.js";
|
import type { MoltbotConfig } from "../../config/config.js";
|
||||||
import { resolveUserPath } from "../../utils.js";
|
import { resolveUserPath } from "../../utils.js";
|
||||||
|
import { setupLlmProxy } from "../llm-proxy.js";
|
||||||
import { loadWebMedia } from "../../web/media.js";
|
import { loadWebMedia } from "../../web/media.js";
|
||||||
import { ensureAuthProfileStore, listProfilesForProvider } from "../auth-profiles.js";
|
import { ensureAuthProfileStore, listProfilesForProvider } from "../auth-profiles.js";
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
||||||
@ -219,6 +220,9 @@ async function runImagePrompt(params: {
|
|||||||
model: string;
|
model: string;
|
||||||
attempts: Array<{ provider: string; model: string; error: 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
|
const effectiveCfg: MoltbotConfig | undefined = params.cfg
|
||||||
? {
|
? {
|
||||||
...params.cfg,
|
...params.cfg,
|
||||||
|
|||||||
516
src/agents/tools/qveris-tools.ts
Normal file
516
src/agents/tools/qveris-tools.ts
Normal file
@ -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<ClawdbotConfig["tools"]>["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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<QverisSearchResponse> {
|
||||||
|
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<string, unknown>;
|
||||||
|
maxResponseSize: number;
|
||||||
|
apiKey: string;
|
||||||
|
baseUrl: string;
|
||||||
|
timeoutSeconds: number;
|
||||||
|
}): Promise<QverisExecutionResponse> {
|
||||||
|
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<string, QverisToolMapping>();
|
||||||
|
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<QverisSearchResponse | null> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
try {
|
||||||
|
toolParams = JSON.parse(paramsToToolRaw) as Record<string, unknown>;
|
||||||
|
} 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));
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import { resolveDiscordAccount } from "../discord/accounts.js";
|
import { resolveDiscordAccount } from "../discord/accounts.js";
|
||||||
|
import { resolveFeishuAccount } from "../feishu/accounts.js";
|
||||||
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
||||||
import { resolveSignalAccount } from "../signal/accounts.js";
|
import { resolveSignalAccount } from "../signal/accounts.js";
|
||||||
import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js";
|
import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js";
|
||||||
@ -213,6 +214,38 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
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: {
|
googlechat: {
|
||||||
id: "googlechat",
|
id: "googlechat",
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export const CHAT_CHANNEL_ORDER = [
|
|||||||
"telegram",
|
"telegram",
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
"discord",
|
"discord",
|
||||||
|
"feishu",
|
||||||
"googlechat",
|
"googlechat",
|
||||||
"slack",
|
"slack",
|
||||||
"signal",
|
"signal",
|
||||||
@ -58,6 +59,16 @@ const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
|
|||||||
blurb: "very well supported right now.",
|
blurb: "very well supported right now.",
|
||||||
systemImage: "bubble.left.and.bubble.right",
|
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: {
|
googlechat: {
|
||||||
id: "googlechat",
|
id: "googlechat",
|
||||||
label: "Google Chat",
|
label: "Google Chat",
|
||||||
@ -104,6 +115,7 @@ export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
|
|||||||
imsg: "imessage",
|
imsg: "imessage",
|
||||||
"google-chat": "googlechat",
|
"google-chat": "googlechat",
|
||||||
gchat: "googlechat",
|
gchat: "googlechat",
|
||||||
|
lark: "feishu",
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeChannelKey = (raw?: string | null): string | undefined => {
|
const normalizeChannelKey = (raw?: string | null): string | undefined => {
|
||||||
|
|||||||
@ -199,6 +199,14 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)",
|
"tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)",
|
||||||
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
|
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
|
||||||
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
"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.basePath": "Control UI Base Path",
|
||||||
"gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
|
"gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
|
||||||
"gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth",
|
"gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth",
|
||||||
@ -462,6 +470,17 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"tools.web.fetch.firecrawl.maxAgeMs":
|
"tools.web.fetch.firecrawl.maxAgeMs":
|
||||||
"Firecrawl maxAge (ms) for cached results when supported by the API.",
|
"Firecrawl maxAge (ms) for cached results when supported by the API.",
|
||||||
"tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.",
|
"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":
|
"channels.slack.allowBots":
|
||||||
"Allow bot-authored messages to trigger Slack replies (default: false).",
|
"Allow bot-authored messages to trigger Slack replies (default: false).",
|
||||||
"channels.slack.thread.historyScope":
|
"channels.slack.thread.historyScope":
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { DiscordConfig } from "./types.discord.js";
|
import type { DiscordConfig } from "./types.discord.js";
|
||||||
|
import type { FeishuConfig } from "./types.feishu.js";
|
||||||
import type { GoogleChatConfig } from "./types.googlechat.js";
|
import type { GoogleChatConfig } from "./types.googlechat.js";
|
||||||
import type { IMessageConfig } from "./types.imessage.js";
|
import type { IMessageConfig } from "./types.imessage.js";
|
||||||
import type { MSTeamsConfig } from "./types.msteams.js";
|
import type { MSTeamsConfig } from "./types.msteams.js";
|
||||||
@ -28,6 +29,7 @@ export type ChannelsConfig = {
|
|||||||
whatsapp?: WhatsAppConfig;
|
whatsapp?: WhatsAppConfig;
|
||||||
telegram?: TelegramConfig;
|
telegram?: TelegramConfig;
|
||||||
discord?: DiscordConfig;
|
discord?: DiscordConfig;
|
||||||
|
feishu?: FeishuConfig;
|
||||||
googlechat?: GoogleChatConfig;
|
googlechat?: GoogleChatConfig;
|
||||||
slack?: SlackConfig;
|
slack?: SlackConfig;
|
||||||
signal?: SignalConfig;
|
signal?: SignalConfig;
|
||||||
|
|||||||
153
src/config/types.feishu.ts
Normal file
153
src/config/types.feishu.ts
Normal file
@ -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<string, FeishuGroupConfig>;
|
||||||
|
|
||||||
|
/** 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<string, DmConfig>;
|
||||||
|
|
||||||
|
/** 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<string, FeishuAccountConfig>;
|
||||||
|
} & FeishuAccountConfig;
|
||||||
@ -56,4 +56,6 @@ export type ModelsConfig = {
|
|||||||
mode?: "merge" | "replace";
|
mode?: "merge" | "replace";
|
||||||
providers?: Record<string, ModelProviderConfig>;
|
providers?: Record<string, ModelProviderConfig>;
|
||||||
bedrockDiscovery?: BedrockDiscoveryConfig;
|
bedrockDiscovery?: BedrockDiscoveryConfig;
|
||||||
|
/** HTTP proxy URL for all LLM API calls (e.g., "http://user:pass@proxy:8080"). */
|
||||||
|
proxy?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -445,4 +445,23 @@ export type ToolsConfig = {
|
|||||||
deny?: string[];
|
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;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export * from "./types.channels.js";
|
|||||||
export * from "./types.clawdbot.js";
|
export * from "./types.clawdbot.js";
|
||||||
export * from "./types.cron.js";
|
export * from "./types.cron.js";
|
||||||
export * from "./types.discord.js";
|
export * from "./types.discord.js";
|
||||||
|
export * from "./types.feishu.js";
|
||||||
export * from "./types.googlechat.js";
|
export * from "./types.googlechat.js";
|
||||||
export * from "./types.gateway.js";
|
export * from "./types.gateway.js";
|
||||||
export * from "./types.hooks.js";
|
export * from "./types.hooks.js";
|
||||||
|
|||||||
@ -533,6 +533,19 @@ export const ToolsSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.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()
|
.strict()
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
|
|||||||
@ -77,6 +77,7 @@ export const ModelsConfigSchema = z
|
|||||||
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
|
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
|
||||||
providers: z.record(z.string(), ModelProviderSchema).optional(),
|
providers: z.record(z.string(), ModelProviderSchema).optional(),
|
||||||
bedrockDiscovery: BedrockDiscoverySchema,
|
bedrockDiscovery: BedrockDiscoverySchema,
|
||||||
|
proxy: z.string().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|||||||
115
src/feishu/accounts.ts
Normal file
115
src/feishu/accounts.ts
Normal file
@ -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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
292
src/feishu/bot.ts
Normal file
292
src/feishu/bot.ts
Normal file
@ -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<string, HistoryEntry[]>();
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
488
src/feishu/client.ts
Normal file
488
src/feishu/client.ts
Normal file
@ -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<T> = {
|
||||||
|
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<string, { token: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant access token for a Feishu app
|
||||||
|
*/
|
||||||
|
export async function getTenantAccessToken(credentials: FeishuCredentials): Promise<string> {
|
||||||
|
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<FeishuTenantAccessToken> & {
|
||||||
|
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<T>(
|
||||||
|
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||||
|
path: string,
|
||||||
|
opts?: {
|
||||||
|
params?: Record<string, string>;
|
||||||
|
body?: unknown;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
},
|
||||||
|
): Promise<FeishuApiResponse<T>> {
|
||||||
|
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<string, string> = {
|
||||||
|
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<T>;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a chat
|
||||||
|
*/
|
||||||
|
async sendMessage(params: FeishuSendMessageParams): Promise<FeishuSendMessageResult> {
|
||||||
|
const result = await this.request<FeishuSendMessageResult>("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<FeishuSendMessageResult> {
|
||||||
|
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<FeishuSendMessageResult> {
|
||||||
|
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<FeishuSendMessageResult> {
|
||||||
|
const result = await this.request<FeishuSendMessageResult>(
|
||||||
|
"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<string, string> = {};
|
||||||
|
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<FeishuChatInfo> {
|
||||||
|
const result = await this.request<FeishuChatInfo>("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<string, string> = { 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<void> {
|
||||||
|
const result = await this.request<void>("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<void> {
|
||||||
|
const result = await this.request<void>("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);
|
||||||
|
}
|
||||||
328
src/feishu/events.ts
Normal file
328
src/feishu/events.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
|
||||||
|
// 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<string, unknown>;
|
||||||
|
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_<number> 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 };
|
||||||
|
}
|
||||||
19
src/feishu/index.ts
Normal file
19
src/feishu/index.ts
Normal file
@ -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";
|
||||||
272
src/feishu/message-dispatch.ts
Normal file
272
src/feishu/message-dispatch.ts
Normal file
@ -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<void> {
|
||||||
|
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)}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
598
src/feishu/monitor.ts
Normal file
598
src/feishu/monitor.ts
Normal file
@ -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<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>((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<void> {
|
||||||
|
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 } };
|
||||||
|
};
|
||||||
|
}
|
||||||
231
src/feishu/send.ts
Normal file
231
src/feishu/send.ts
Normal file
@ -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<SendFeishuMessageResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/feishu/token.ts
Normal file
102
src/feishu/token.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { complete } from "@mariozechner/pi-ai";
|
|||||||
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
import { getApiKeyForModel, requireApiKey } from "../../agents/model-auth.js";
|
import { getApiKeyForModel, requireApiKey } from "../../agents/model-auth.js";
|
||||||
|
import { setupLlmProxy } from "../../agents/llm-proxy.js";
|
||||||
import { ensureMoltbotModelsJson } from "../../agents/models-config.js";
|
import { ensureMoltbotModelsJson } from "../../agents/models-config.js";
|
||||||
import { minimaxUnderstandImage } from "../../agents/minimax-vlm.js";
|
import { minimaxUnderstandImage } from "../../agents/minimax-vlm.js";
|
||||||
import { coerceImageAssistantText } from "../../agents/tools/image-tool.helpers.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(
|
export async function describeImageWithModel(
|
||||||
params: ImageDescriptionRequest,
|
params: ImageDescriptionRequest,
|
||||||
): Promise<ImageDescriptionResult> {
|
): Promise<ImageDescriptionResult> {
|
||||||
|
// Set up LLM proxy if configured (must be done before any fetch calls)
|
||||||
|
setupLlmProxy(params.cfg);
|
||||||
|
|
||||||
await ensureMoltbotModelsJson(params.cfg, params.agentDir);
|
await ensureMoltbotModelsJson(params.cfg, params.agentDir);
|
||||||
const authStorage = discoverAuthStorage(params.agentDir);
|
const authStorage = discoverAuthStorage(params.agentDir);
|
||||||
const modelRegistry = discoverModels(authStorage, params.agentDir);
|
const modelRegistry = discoverModels(authStorage, params.agentDir);
|
||||||
|
|||||||
22
test_scripts/feishu_server.py
Normal file
22
test_scripts/feishu_server.py
Normal file
@ -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()
|
||||||
|
|
||||||
262
test_scripts/get_bitable_info.py
Executable file
262
test_scripts/get_bitable_info.py
Executable file
@ -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 <feishu_url>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 <feishu_url>")
|
||||||
|
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 = \"<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)
|
||||||
413
test_scripts/send_feishu_message.py
Executable file
413
test_scripts/send_feishu_message.py
Executable file
@ -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()
|
||||||
728
test_scripts/sync_providers_to_feishu.py
Executable file
728
test_scripts/sync_providers_to_feishu.py
Executable file
@ -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())
|
||||||
320
test_scripts/test_feishu_connection.py
Executable file
320
test_scripts/test_feishu_connection.py
Executable file
@ -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)
|
||||||
64
test_scripts/test_feishu_websocket.ts
Normal file
64
test_scripts/test_feishu_websocket.ts
Normal file
@ -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();
|
||||||
Loading…
Reference in New Issue
Block a user