add feishu support

This commit is contained in:
alex xiang 2026-01-27 21:49:13 +08:00
parent bb8bf71b28
commit 79a3a967f9
31 changed files with 5244 additions and 3123 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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>

View File

@ -0,0 +1,11 @@
{
"id": "feishu",
"channels": [
"feishu"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View 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
View 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
}
}
}
}
}

View 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"
}
}

View 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;
},
},
};

View File

@ -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
View File

@ -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:

View File

@ -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: {

View File

@ -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 => {

View File

@ -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
View 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;

View File

@ -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
View 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
View 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
View 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
View 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
View 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";

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

View 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
View 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)

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

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

View 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)

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