add feishu support
This commit is contained in:
parent
bb8bf71b28
commit
79a3a967f9
1
dist/control-ui/assets/index-08nzABV3.css
vendored
1
dist/control-ui/assets/index-08nzABV3.css
vendored
File diff suppressed because one or more lines are too long
3119
dist/control-ui/assets/index-DQcOTEYz.js
vendored
3119
dist/control-ui/assets/index-DQcOTEYz.js
vendored
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-DQcOTEYz.js.map
vendored
1
dist/control-ui/assets/index-DQcOTEYz.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/control-ui/index.html
vendored
4
dist/control-ui/index.html
vendored
@ -6,8 +6,8 @@
|
||||
<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-DQcOTEYz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
|
||||
<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>
|
||||
|
||||
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;
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -157,6 +157,7 @@
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.4",
|
||||
"@larksuiteoapi/node-sdk": "^1.56.1",
|
||||
"@line/bot-sdk": "^10.6.0",
|
||||
"@lydell/node-pty": "1.2.0-beta.3",
|
||||
"@mariozechner/pi-agent-core": "0.49.3",
|
||||
|
||||
51
pnpm-lock.yaml
generated
51
pnpm-lock.yaml
generated
@ -34,6 +34,9 @@ importers:
|
||||
'@homebridge/ciao':
|
||||
specifier: ^1.3.4
|
||||
version: 1.3.4
|
||||
'@larksuiteoapi/node-sdk':
|
||||
specifier: ^1.56.1
|
||||
version: 1.56.1
|
||||
'@line/bot-sdk':
|
||||
specifier: ^10.6.0
|
||||
version: 10.6.0
|
||||
@ -304,6 +307,12 @@ importers:
|
||||
|
||||
extensions/discord: {}
|
||||
|
||||
extensions/feishu:
|
||||
dependencies:
|
||||
'@larksuiteoapi/node-sdk':
|
||||
specifier: ^1.56.1
|
||||
version: 1.56.1
|
||||
|
||||
extensions/google-antigravity-auth: {}
|
||||
|
||||
extensions/google-gemini-cli-auth: {}
|
||||
@ -1321,6 +1330,9 @@ packages:
|
||||
peerDependencies:
|
||||
apache-arrow: '>=15.0.0 <=18.1.0'
|
||||
|
||||
'@larksuiteoapi/node-sdk@1.56.1':
|
||||
resolution: {integrity: sha512-/ixtyJnWOmcupKgDXz+6G6qTLMi3cNrR+LGOuq2PMwcJ6hhXTUJNyAF+ADY7ah9OoeDniGU/UJwMb2gqKdxwcA==}
|
||||
|
||||
'@line/bot-sdk@10.6.0':
|
||||
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
|
||||
engines: {node: '>=20'}
|
||||
@ -3065,6 +3077,9 @@ packages:
|
||||
aws4@1.13.2:
|
||||
resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
|
||||
|
||||
axios@0.27.2:
|
||||
resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==}
|
||||
|
||||
axios@1.13.2:
|
||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||
|
||||
@ -4159,6 +4174,9 @@ packages:
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.identity@3.0.0:
|
||||
resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==}
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
@ -4177,9 +4195,15 @@ packages:
|
||||
lodash.isstring@4.0.1:
|
||||
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
lodash.pickby@4.6.0:
|
||||
resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==}
|
||||
|
||||
lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
|
||||
@ -6876,6 +6900,20 @@ snapshots:
|
||||
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
||||
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
|
||||
|
||||
'@larksuiteoapi/node-sdk@1.56.1':
|
||||
dependencies:
|
||||
axios: 0.27.2
|
||||
lodash.identity: 3.0.0
|
||||
lodash.merge: 4.6.2
|
||||
lodash.pickby: 4.6.0
|
||||
protobufjs: 7.5.4
|
||||
qs: 6.14.1
|
||||
ws: 8.19.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- debug
|
||||
- utf-8-validate
|
||||
|
||||
'@line/bot-sdk@10.6.0':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
@ -8923,6 +8961,13 @@ snapshots:
|
||||
|
||||
aws4@1.13.2: {}
|
||||
|
||||
axios@0.27.2:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11(debug@4.4.3)
|
||||
form-data: 4.0.5
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
axios@1.13.2(debug@4.4.3):
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11(debug@4.4.3)
|
||||
@ -10245,6 +10290,8 @@ snapshots:
|
||||
lodash.debounce@4.0.8:
|
||||
optional: true
|
||||
|
||||
lodash.identity@3.0.0: {}
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
@ -10257,8 +10304,12 @@ snapshots:
|
||||
|
||||
lodash.isstring@4.0.1: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
lodash.pickby@4.6.0: {}
|
||||
|
||||
lodash@4.17.23: {}
|
||||
|
||||
log-symbols@6.0.0:
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveDiscordAccount } from "../discord/accounts.js";
|
||||
import { resolveFeishuAccount } from "../feishu/accounts.js";
|
||||
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
||||
import { resolveSignalAccount } from "../signal/accounts.js";
|
||||
import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js";
|
||||
@ -213,6 +214,38 @@ const DOCKS: Record<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: {
|
||||
id: "googlechat",
|
||||
capabilities: {
|
||||
|
||||
@ -8,6 +8,7 @@ export const CHAT_CHANNEL_ORDER = [
|
||||
"telegram",
|
||||
"whatsapp",
|
||||
"discord",
|
||||
"feishu",
|
||||
"googlechat",
|
||||
"slack",
|
||||
"signal",
|
||||
@ -58,6 +59,16 @@ const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
|
||||
blurb: "very well supported right now.",
|
||||
systemImage: "bubble.left.and.bubble.right",
|
||||
},
|
||||
feishu: {
|
||||
id: "feishu",
|
||||
label: "Feishu",
|
||||
selectionLabel: "Feishu (Lark Bot API)",
|
||||
detailLabel: "Feishu Bot",
|
||||
docsPath: "/channels/feishu",
|
||||
docsLabel: "feishu",
|
||||
blurb: "Feishu/Lark bot with webhook or WebSocket event subscription.",
|
||||
systemImage: "message.badge.circle",
|
||||
},
|
||||
googlechat: {
|
||||
id: "googlechat",
|
||||
label: "Google Chat",
|
||||
@ -104,6 +115,7 @@ export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
|
||||
imsg: "imessage",
|
||||
"google-chat": "googlechat",
|
||||
gchat: "googlechat",
|
||||
lark: "feishu",
|
||||
};
|
||||
|
||||
const normalizeChannelKey = (raw?: string | null): string | undefined => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { DiscordConfig } from "./types.discord.js";
|
||||
import type { FeishuConfig } from "./types.feishu.js";
|
||||
import type { GoogleChatConfig } from "./types.googlechat.js";
|
||||
import type { IMessageConfig } from "./types.imessage.js";
|
||||
import type { MSTeamsConfig } from "./types.msteams.js";
|
||||
@ -28,6 +29,7 @@ export type ChannelsConfig = {
|
||||
whatsapp?: WhatsAppConfig;
|
||||
telegram?: TelegramConfig;
|
||||
discord?: DiscordConfig;
|
||||
feishu?: FeishuConfig;
|
||||
googlechat?: GoogleChatConfig;
|
||||
slack?: SlackConfig;
|
||||
signal?: SignalConfig;
|
||||
|
||||
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;
|
||||
@ -10,6 +10,7 @@ export * from "./types.channels.js";
|
||||
export * from "./types.clawdbot.js";
|
||||
export * from "./types.cron.js";
|
||||
export * from "./types.discord.js";
|
||||
export * from "./types.feishu.js";
|
||||
export * from "./types.googlechat.js";
|
||||
export * from "./types.gateway.js";
|
||||
export * from "./types.hooks.js";
|
||||
|
||||
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)}`));
|
||||
}
|
||||
}
|
||||
548
src/feishu/monitor.ts
Normal file
548
src/feishu/monitor.ts
Normal file
@ -0,0 +1,548 @@
|
||||
/**
|
||||
* 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 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,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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...`);
|
||||
try {
|
||||
const botInfo = await client.getBotInfo();
|
||||
log(`feishu: connected as "${botInfo.app_name}" (${botInfo.open_id})`);
|
||||
} catch (err) {
|
||||
throw new Error(`Feishu authentication failed: ${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();
|
||||
}
|
||||
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