feat(feishu): carry Lighthouse bridge into v0.8.37

Add the Feishu/Lark long-connection bridge, Tencent Lighthouse runbooks, CNB mirror guidance, CNB tag release pipeline, and China-friendly update fallback documentation for the v0.8.37 line.
This commit is contained in:
Hunter Bown
2026-05-14 03:56:03 -05:00
committed by GitHub
parent 019d55694a
commit 9483248a9f
32 changed files with 3795 additions and 35 deletions
+24
View File
@@ -0,0 +1,24 @@
FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxx
FEISHU_APP_SECRET=replace-with-app-secret
FEISHU_DOMAIN=feishu
DEEPSEEK_RUNTIME_URL=http://127.0.0.1:7878
DEEPSEEK_RUNTIME_TOKEN=replace-with-long-random-token
DEEPSEEK_WORKSPACE=/opt/whalebro
DEEPSEEK_MODEL=auto
DEEPSEEK_MODE=agent
DEEPSEEK_ALLOW_SHELL=true
DEEPSEEK_TRUST_MODE=false
DEEPSEEK_AUTO_APPROVE=false
# Comma-separated chat IDs, open IDs, or union IDs allowed to control the runtime.
# Leave empty only during first pairing, with DEEPSEEK_ALLOW_UNLISTED=true.
DEEPSEEK_CHAT_ALLOWLIST=
DEEPSEEK_ALLOW_UNLISTED=false
FEISHU_THREAD_MAP_PATH=/var/lib/deepseek-feishu-bridge/thread-map.json
FEISHU_ALLOW_GROUPS=false
FEISHU_REQUIRE_PREFIX_IN_GROUP=true
FEISHU_GROUP_PREFIX=/ds
FEISHU_MAX_REPLY_CHARS=3500
DEEPSEEK_TURN_TIMEOUT_MS=900000
+60
View File
@@ -0,0 +1,60 @@
# Feishu / Lark Bridge
This bridge lets a Feishu or Lark chat control a local `deepseek serve --http`
runtime from a phone. It uses the official Lark/Feishu Node SDK long-connection
mode, so the first version does not need a public webhook URL.
Security model:
- `deepseek serve --http` stays bound to `127.0.0.1`.
- `/v1/*` runtime calls use `DEEPSEEK_RUNTIME_TOKEN`.
- Feishu/Lark chats must be allowlisted unless `DEEPSEEK_ALLOW_UNLISTED=true`
is set for first pairing.
- Direct messages are the intended MVP control surface. Group chat control is
disabled unless `FEISHU_ALLOW_GROUPS=true`.
- Tool approvals are text commands: `/allow <approval_id>` or `/deny <approval_id>`.
## Setup
```bash
cd /opt/deepseek/bridge
npm install --omit=dev
cp .env.example /etc/deepseek/feishu-bridge.env
sudoedit /etc/deepseek/feishu-bridge.env
node src/index.mjs
```
Validate the env files before starting the service:
```bash
npm run validate:config -- \
--env /etc/deepseek/feishu-bridge.env \
--runtime-env /etc/deepseek/runtime.env \
--workspace-root /opt/whalebro \
--check-filesystem
```
For a Tencent Lighthouse deployment, use:
```bash
sudo systemctl enable --now deepseek-runtime deepseek-feishu-bridge
sudo journalctl -u deepseek-feishu-bridge -f
```
## Commands
- `/status`
- `/threads`
- `/new`
- `/resume <thread_id>`
- `/interrupt`
- `/compact`
- `/allow <approval_id> [remember]`
- `/deny <approval_id>`
Anything else is sent as a prompt. If group control is explicitly enabled,
messages must start with `/ds` by default, for example:
```text
/ds check git status and tell me what is dirty
```
+627
View File
@@ -0,0 +1,627 @@
{
"name": "@deepseek-tui/feishu-bridge",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@deepseek-tui/feishu-bridge",
"version": "0.1.0",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.52.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@larksuiteoapi/node-sdk": {
"version": "1.63.1",
"resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.63.1.tgz",
"integrity": "sha512-bVC2QVkITZ1i6kLP7hI7DXtp61ic9shP/F+bp/2qZ0ISSvrcHp2euu1xt6C29jPJVNieRgvdsBPuapOlybviVw==",
"license": "MIT",
"dependencies": {
"axios": "~1.13.3",
"lodash.identity": "^3.0.0",
"lodash.merge": "^4.6.2",
"lodash.pickby": "^4.6.0",
"protobufjs": "^7.2.6",
"qs": "^6.14.2",
"ws": "^8.19.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.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"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.1",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
"integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
"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.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@types/node": {
"version": "25.7.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz",
"integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.21.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.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": "1.16.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.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/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"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.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"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.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"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/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"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.8",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz",
"integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.1",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"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.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"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.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz",
"integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"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
}
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@deepseek-tui/feishu-bridge",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Feishu/Lark mobile bridge for a local deepseek serve --http runtime.",
"main": "src/index.mjs",
"scripts": {
"start": "node src/index.mjs",
"check": "node --check src/index.mjs && node --check src/lib.mjs && node --check scripts/validate-config.mjs",
"test": "node --test test/*.test.mjs",
"validate:config": "node scripts/validate-config.mjs"
},
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.52.0"
},
"overrides": {
"axios": "^1.16.1"
},
"engines": {
"node": ">=18"
}
}
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env node
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import {
cleanEnvValue,
formatValidationReport,
parseEnvText,
validateBridgeConfig
} from "../src/lib.mjs";
const args = parseArgs(process.argv.slice(2));
try {
const bridgeEnv = args.env ? parseEnvText(await fs.readFile(args.env, "utf8")) : process.env;
const runtimeEnv = args.runtimeEnv
? parseEnvText(await fs.readFile(args.runtimeEnv, "utf8"))
: null;
const result = validateBridgeConfig(bridgeEnv, {
runtimeEnv,
workspaceRoot: args.workspaceRoot || "/opt/whalebro"
});
if (args.checkFilesystem) {
await appendFilesystemChecks(result, bridgeEnv, args);
}
if (args.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(formatValidationReport(result));
}
process.exitCode = result.ok ? 0 : 1;
} catch (error) {
console.error(`Config validation failed: ${error.message}`);
process.exitCode = 1;
}
function parseArgs(argv) {
const parsed = {
env: "",
runtimeEnv: "",
workspaceRoot: "/opt/whalebro",
checkFilesystem: false,
json: false
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--env":
parsed.env = argv[++index];
break;
case "--runtime-env":
parsed.runtimeEnv = argv[++index];
break;
case "--workspace-root":
parsed.workspaceRoot = argv[++index];
break;
case "--check-filesystem":
parsed.checkFilesystem = true;
break;
case "--json":
parsed.json = true;
break;
case "-h":
case "--help":
printHelp();
process.exit(0);
break;
default:
throw new Error(`unknown argument: ${arg}`);
}
}
return parsed;
}
async function appendFilesystemChecks(result, env, args) {
const workspace = cleanEnvValue(env.DEEPSEEK_WORKSPACE);
if (workspace) {
await checkReadableDirectory(result, workspace, "workspace");
}
const threadMapPath = cleanEnvValue(env.FEISHU_THREAD_MAP_PATH);
if (threadMapPath) {
const parent = path.dirname(threadMapPath);
await checkWritableDirectory(result, parent, "thread map directory");
}
if (args.env) {
await checkReadableFile(result, args.env, "bridge env file");
}
if (args.runtimeEnv) {
await checkReadableFile(result, args.runtimeEnv, "runtime env file");
}
}
async function checkReadableDirectory(result, dir, label) {
try {
const stat = await fs.stat(dir);
if (!stat.isDirectory()) {
result.errors.push({ code: "not_directory", message: `${label} is not a directory: ${dir}` });
result.ok = false;
return;
}
await fs.access(dir, fsConstants.R_OK | fsConstants.X_OK);
result.info.push({ code: "readable_directory", message: `${label} is readable: ${dir}` });
} catch (error) {
result.errors.push({ code: "directory_access", message: `${label} is not readable: ${dir}` });
result.ok = false;
}
}
async function checkWritableDirectory(result, dir, label) {
try {
const stat = await fs.stat(dir);
if (!stat.isDirectory()) {
result.errors.push({ code: "not_directory", message: `${label} is not a directory: ${dir}` });
result.ok = false;
return;
}
await fs.access(dir, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK);
result.info.push({ code: "writable_directory", message: `${label} is writable: ${dir}` });
} catch {
result.errors.push({ code: "directory_access", message: `${label} is not writable: ${dir}` });
result.ok = false;
}
}
async function checkReadableFile(result, filePath, label) {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
result.errors.push({ code: "not_file", message: `${label} is not a file: ${filePath}` });
result.ok = false;
return;
}
await fs.access(filePath, fsConstants.R_OK);
result.info.push({ code: "readable_file", message: `${label} is readable: ${filePath}` });
} catch {
result.errors.push({ code: "file_access", message: `${label} is not readable: ${filePath}` });
result.ok = false;
}
}
function printHelp() {
console.log(`Usage: node scripts/validate-config.mjs [options]
Options:
--env FILE Read bridge env from FILE instead of process.env.
--runtime-env FILE Read runtime env and verify the shared bearer token.
--workspace-root DIR Expected remote workspace root (default: /opt/whalebro).
--check-filesystem Verify workspace and thread-map paths are usable.
--json Print machine-readable JSON.
-h, --help Show this help.
`);
}
+570
View File
@@ -0,0 +1,570 @@
import fs from "node:fs/promises";
import path from "node:path";
import * as Lark from "@larksuiteoapi/node-sdk";
import {
activeTurnBlock,
commandAction,
compactRuntimeError,
helpText,
incomingIdentity,
isAllowed,
latestRunningTurn,
pairingRefusalText,
parseBool,
parseCommand,
parseList,
parseApprovalDecisionArgs,
parseTextContent,
splitMessage,
stripGroupPrefix
} from "./lib.mjs";
const config = {
appId: requiredEnv("FEISHU_APP_ID"),
appSecret: requiredEnv("FEISHU_APP_SECRET"),
domain: process.env.FEISHU_DOMAIN || "feishu",
runtimeUrl: (process.env.DEEPSEEK_RUNTIME_URL || "http://127.0.0.1:7878").replace(/\/+$/, ""),
runtimeToken: requiredEnv("DEEPSEEK_RUNTIME_TOKEN"),
workspace: process.env.DEEPSEEK_WORKSPACE || process.cwd(),
model: process.env.DEEPSEEK_MODEL || "auto",
mode: process.env.DEEPSEEK_MODE || "agent",
allowShell: parseBool(process.env.DEEPSEEK_ALLOW_SHELL, true),
trustMode: parseBool(process.env.DEEPSEEK_TRUST_MODE, false),
autoApprove: parseBool(process.env.DEEPSEEK_AUTO_APPROVE, false),
allowlist: parseList(process.env.DEEPSEEK_CHAT_ALLOWLIST),
allowUnlisted: parseBool(process.env.DEEPSEEK_ALLOW_UNLISTED, false),
threadMapPath:
process.env.FEISHU_THREAD_MAP_PATH ||
"/var/lib/deepseek-feishu-bridge/thread-map.json",
allowGroups: parseBool(process.env.FEISHU_ALLOW_GROUPS, false),
requirePrefixInGroup: parseBool(process.env.FEISHU_REQUIRE_PREFIX_IN_GROUP, true),
groupPrefix: process.env.FEISHU_GROUP_PREFIX || "/ds",
maxReplyChars: Number(process.env.FEISHU_MAX_REPLY_CHARS || 3500),
turnTimeoutMs: Number(process.env.DEEPSEEK_TURN_TIMEOUT_MS || 900000)
};
const sdkConfig = {
appId: config.appId,
appSecret: config.appSecret,
domain: resolveLarkDomain(config.domain)
};
const client = new Lark.Client(sdkConfig);
const wsClient = new Lark.WSClient({
...sdkConfig,
loggerLevel: Lark.LoggerLevel?.info
});
const threadStore = await ThreadStore.open(config.threadMapPath);
const dispatcher = new Lark.EventDispatcher({}).register({
"im.message.receive_v1": async (data) => {
void handleIncomingMessage(data).catch((error) => {
console.error("failed to handle incoming Feishu message", error);
});
}
});
console.log("Starting DeepSeek Feishu bridge");
console.log(`Runtime: ${config.runtimeUrl}`);
console.log(`Workspace: ${config.workspace}`);
if (!config.allowlist.length && !config.allowUnlisted) {
console.log("No allowlist configured. Incoming chats will receive their IDs and be refused.");
}
wsClient.start({ eventDispatcher: dispatcher });
async function handleIncomingMessage(event) {
const identity = incomingIdentity(event);
if (!identity.chatId) return;
if (identity.messageType && identity.messageType !== "text") {
await sendText(identity.chatId, "Only text messages are supported in this first bridge.");
return;
}
const rawText = parseTextContent(event.message?.content || "");
const scoped = stripGroupPrefix(rawText, {
chatType: identity.chatType,
requirePrefix: config.requirePrefixInGroup,
prefix: config.groupPrefix
});
if (!scoped.accepted) return;
if (identity.messageId && (await threadStore.recordMessage(identity.messageId))) {
return;
}
if (identity.chatType !== "p2p" && !config.allowGroups) {
await sendText(
identity.chatId,
"Group chat control is disabled for this bridge. DM the bot, or set FEISHU_ALLOW_GROUPS=true and allowlist this chat."
);
return;
}
if (!isAllowed(identity, config.allowlist, config.allowUnlisted)) {
await sendText(identity.chatId, pairingRefusalText(identity));
return;
}
const command = parseCommand(scoped.text);
await handleCommand(identity.chatId, command);
}
async function handleCommand(chatId, command) {
const action = commandAction(command);
switch (action.kind) {
case "help":
await sendText(chatId, helpText());
return;
case "status":
await sendStatus(chatId);
return;
case "threads":
await sendThreads(chatId);
return;
case "new_thread": {
const state = await ensureThread(chatId, { forceNew: true });
await sendText(chatId, `Created thread ${state.threadId}`);
return;
}
case "resume":
await resumeThread(chatId, action.threadId);
return;
case "interrupt":
await interruptActiveTurn(chatId);
return;
case "compact":
await compactThread(chatId);
return;
case "approval":
await decideApproval(chatId, action);
return;
case "prompt":
await runPrompt(chatId, action.prompt);
return;
default:
await sendText(chatId, helpText());
}
}
async function ensureThread(chatId, { forceNew = false } = {}) {
const existing = await threadStore.getChat(chatId);
if (existing?.threadId && !forceNew) return existing;
const thread = await runtimeJson("/v1/threads", {
method: "POST",
body: {
model: config.model,
workspace: config.workspace,
mode: config.mode,
allow_shell: config.allowShell,
trust_mode: config.trustMode,
auto_approve: config.autoApprove,
archived: false,
system_prompt:
"You are being controlled from a Feishu/Lark phone chat. Keep status updates concise. Ask for tool approvals when needed; do not assume mobile messages imply blanket approval."
}
});
const state = {
threadId: thread.id,
lastSeq: 0,
activeTurnId: null,
updatedAt: new Date().toISOString()
};
await threadStore.setChat(chatId, state);
return state;
}
async function runPrompt(chatId, prompt) {
if (!prompt.trim()) {
await sendText(chatId, helpText());
return;
}
const state = await ensureThread(chatId);
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
const activeBlock = activeTurnBlock(detail, state);
if (activeBlock) {
await threadStore.patchChat(chatId, {
activeTurnId: activeBlock.turnId,
updatedAt: new Date().toISOString()
});
await sendText(chatId, activeBlock.message);
return;
}
if (state.activeTurnId) {
await threadStore.patchChat(chatId, { activeTurnId: null });
}
const sinceSeq = Number(detail.latest_seq || state.lastSeq || 0);
const turnResponse = await runtimeJson(
`/v1/threads/${encodeURIComponent(state.threadId)}/turns`,
{
method: "POST",
body: {
prompt,
input_summary: prompt.slice(0, 200),
model: config.model,
mode: config.mode,
allow_shell: config.allowShell,
trust_mode: config.trustMode,
auto_approve: config.autoApprove
}
}
);
const turnId = turnResponse.turn?.id;
await threadStore.patchChat(chatId, {
activeTurnId: turnId || null,
lastSeq: sinceSeq,
updatedAt: new Date().toISOString()
});
await sendText(chatId, `Started turn ${turnId || "(unknown)"}`);
try {
await streamTurnEvents(chatId, state.threadId, turnId, sinceSeq);
} finally {
await threadStore.patchChat(chatId, {
activeTurnId: null,
updatedAt: new Date().toISOString()
});
}
}
async function streamTurnEvents(chatId, threadId, turnId, sinceSeq) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.turnTimeoutMs);
let responseText = "";
let latestSeq = sinceSeq;
let sentProgressAt = Date.now();
try {
const response = await fetch(
`${config.runtimeUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=${sinceSeq}`,
{
headers: authHeaders(),
signal: controller.signal
}
);
if (!response.ok) {
const body = await readJsonSafe(response);
throw new Error(compactRuntimeError(response.status, body));
}
for await (const event of readSse(response)) {
if (!event.data) continue;
const record = JSON.parse(event.data);
latestSeq = Math.max(latestSeq, Number(record.seq || 0));
await threadStore.patchChat(chatId, { lastSeq: latestSeq });
if (turnId && record.turn_id && record.turn_id !== turnId) continue;
if (record.event === "item.delta" && record.payload?.kind === "agent_message") {
responseText += record.payload.delta || "";
const now = Date.now();
if (responseText.length > config.maxReplyChars && now - sentProgressAt > 15000) {
await sendText(chatId, responseText.slice(0, config.maxReplyChars));
responseText = responseText.slice(config.maxReplyChars);
sentProgressAt = now;
}
}
if (record.event === "approval.required") {
const approval = record.payload || {};
await sendText(
chatId,
[
"Approval required",
`tool=${approval.tool_name || "unknown"}`,
`approval_id=${approval.approval_id || approval.id}`,
approval.description || "",
"",
`Reply /allow ${approval.approval_id || approval.id}`,
`Reply /deny ${approval.approval_id || approval.id}`
]
.filter(Boolean)
.join("\n")
);
}
if (record.event === "turn.completed") {
const turn = record.payload?.turn || {};
const status = turn.status || "completed";
const error = turn.error ? `\n${turn.error}` : "";
if (status !== "completed") {
await sendText(chatId, `Turn ${status}.${error}`.trim());
} else {
await sendText(chatId, responseText.trim() || "Turn completed.");
}
return;
}
if (record.event === "turn.lifecycle") {
const status = record.payload?.turn?.status || record.payload?.status;
if (["failed", "canceled", "interrupted"].includes(status)) {
await sendText(chatId, `Turn ${status}.`);
return;
}
}
}
} catch (error) {
if (error.name === "AbortError") {
await sendText(chatId, `Turn timed out after ${Math.round(config.turnTimeoutMs / 1000)}s.`);
return;
}
throw error;
} finally {
clearTimeout(timeout);
}
}
async function sendStatus(chatId) {
const [health, runtimeInfo, workspace] = await Promise.all([
runtimeJson("/health", { auth: false }),
runtimeJson("/v1/runtime/info"),
runtimeJson("/v1/workspace/status")
]);
await sendText(
chatId,
[
`runtime=${health.status || "unknown"}`,
`version=${runtimeInfo.version || "unknown"}`,
`bind=${runtimeInfo.bind_host}:${runtimeInfo.port}`,
`auth_required=${runtimeInfo.auth_required}`,
`workspace=${workspace.workspace}`,
`git_repo=${workspace.git_repo}`,
workspace.branch ? `branch=${workspace.branch}` : "",
`staged=${workspace.staged} unstaged=${workspace.unstaged} untracked=${workspace.untracked}`
]
.filter(Boolean)
.join("\n")
);
}
async function sendThreads(chatId) {
const threads = await runtimeJson("/v1/threads/summary?limit=8&include_archived=true");
if (!threads.length) {
await sendText(chatId, "No runtime threads yet.");
return;
}
await sendText(
chatId,
threads
.map((thread) => {
const status = thread.latest_turn_status || "none";
return `${thread.id} [${status}] ${thread.title || thread.preview || ""}`;
})
.join("\n")
);
}
async function resumeThread(chatId, args) {
const threadId = args.trim();
if (!threadId) {
await sendText(chatId, "Usage: /resume <thread_id>");
return;
}
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`);
await threadStore.setChat(chatId, {
threadId,
lastSeq: Number(detail.latest_seq || 0),
activeTurnId: null,
updatedAt: new Date().toISOString()
});
await sendText(chatId, `Resumed thread ${threadId}`);
}
async function interruptActiveTurn(chatId) {
const state = await threadStore.getChat(chatId);
if (!state?.threadId) {
await sendText(chatId, "No runtime thread recorded for this chat.");
return;
}
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
const runningTurn = latestRunningTurn(detail);
const turnId = state.activeTurnId || runningTurn?.id;
if (!turnId) {
await sendText(chatId, "No active turn recorded for this chat.");
return;
}
await runtimeJson(
`/v1/threads/${encodeURIComponent(state.threadId)}/turns/${encodeURIComponent(
turnId
)}/interrupt`,
{ method: "POST" }
);
await threadStore.patchChat(chatId, {
activeTurnId: turnId,
updatedAt: new Date().toISOString()
});
await sendText(chatId, `Interrupt requested for ${turnId}`);
}
async function compactThread(chatId) {
const state = await ensureThread(chatId);
const result = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}/compact`, {
method: "POST",
body: { reason: "phone bridge request" }
});
await sendText(chatId, `Compaction started: ${result.turn?.id || "unknown turn"}`);
}
async function decideApproval(chatId, action) {
const decision = action.decision;
const { approvalId, remember } =
action.approvalId != null ? action : parseApprovalDecisionArgs(action.args);
if (!approvalId) {
await sendText(chatId, `Usage: /${decision} <approval_id>${decision === "allow" ? " [remember]" : ""}`);
return;
}
await runtimeJson(`/v1/approvals/${encodeURIComponent(approvalId)}`, {
method: "POST",
body: { decision, remember }
});
await sendText(chatId, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`);
}
async function sendText(chatId, text) {
const createMessage =
client.im?.v1?.message?.create?.bind(client.im.v1.message) ||
client.im?.message?.create?.bind(client.im.message);
if (!createMessage) {
throw new Error("Lark SDK client does not expose im message create API");
}
for (const chunk of splitMessage(text, config.maxReplyChars)) {
await createMessage({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "text",
content: JSON.stringify({ text: chunk })
}
});
}
}
async function runtimeJson(route, options = {}) {
const response = await fetch(`${config.runtimeUrl}${route}`, {
method: options.method || "GET",
headers: {
...(options.auth === false ? {} : authHeaders()),
...(options.body ? { "content-type": "application/json" } : {})
},
body: options.body ? JSON.stringify(options.body) : undefined
});
const body = await readJsonSafe(response);
if (!response.ok) {
throw new Error(compactRuntimeError(response.status, body));
}
return body;
}
function authHeaders() {
return { authorization: `Bearer ${config.runtimeToken}` };
}
async function readJsonSafe(response) {
const text = await response.text();
if (!text) return {};
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function* readSse(response) {
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
let boundary;
while ((boundary = buffer.indexOf("\n\n")) >= 0) {
const raw = buffer.slice(0, boundary).replace(/\r/g, "");
buffer = buffer.slice(boundary + 2);
const event = { event: "", data: "" };
for (const line of raw.split("\n")) {
if (line.startsWith("event:")) event.event = line.slice(6).trim();
if (line.startsWith("data:")) event.data += line.slice(5).trim();
}
yield event;
}
}
}
function requiredEnv(name) {
const value = process.env[name];
if (!value || !value.trim()) {
throw new Error(`${name} is required`);
}
return value.trim();
}
function resolveLarkDomain(domain) {
const normalized = String(domain || "feishu").toLowerCase();
if (normalized === "lark") return Lark.Domain?.Lark || "https://open.larksuite.com";
if (normalized === "feishu") return Lark.Domain?.Feishu || "https://open.feishu.cn";
return domain;
}
class ThreadStore {
static async open(filePath) {
const store = new ThreadStore(filePath);
await store.load();
return store;
}
constructor(filePath) {
this.filePath = filePath;
this.data = { chats: {} };
}
async load() {
try {
const raw = await fs.readFile(this.filePath, "utf8");
this.data = JSON.parse(raw);
if (!this.data.chats) this.data.chats = {};
if (!this.data.messages) this.data.messages = [];
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
}
async recordMessage(messageId) {
if (!messageId) return false;
if (!Array.isArray(this.data.messages)) this.data.messages = [];
if (this.data.messages.includes(messageId)) return true;
this.data.messages.push(messageId);
this.data.messages = this.data.messages.slice(-200);
await this.save();
return false;
}
async getChat(chatId) {
return this.data.chats[chatId] || null;
}
async setChat(chatId, state) {
this.data.chats[chatId] = state;
await this.save();
return state;
}
async patchChat(chatId, patch) {
const current = this.data.chats[chatId] || {};
this.data.chats[chatId] = { ...current, ...patch };
await this.save();
return this.data.chats[chatId];
}
async save() {
const dir = path.dirname(this.filePath);
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
const tmp = `${this.filePath}.tmp`;
await fs.writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 });
await fs.rename(tmp, this.filePath);
}
}
+344
View File
@@ -0,0 +1,344 @@
export function parseList(raw) {
return String(raw || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
export function parseBool(raw, fallback = false) {
if (raw == null || raw === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(raw).trim().toLowerCase());
}
export function parseEnvText(raw) {
const env = {};
for (const line of String(raw || "").split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const normalized = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed;
const index = normalized.indexOf("=");
if (index <= 0) continue;
const key = normalized.slice(0, index).trim();
let value = normalized.slice(index + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
}
export function cleanEnvValue(value) {
return String(value ?? "").trim();
}
export function isPlaceholderValue(value) {
const normalized = cleanEnvValue(value).toLowerCase();
return (
!normalized ||
normalized.includes("replace-with") ||
normalized.includes("xxxxxxxx") ||
normalized === "changeme"
);
}
export function parseTextContent(content) {
if (typeof content !== "string") return "";
try {
const parsed = JSON.parse(content);
if (typeof parsed.text === "string") return parsed.text;
if (typeof parsed.content === "string") return parsed.content;
} catch {
return content;
}
return content;
}
export function incomingIdentity(event) {
const sender = event?.sender?.sender_id || {};
const message = event?.message || {};
return {
chatId: message.chat_id || "",
messageId: message.message_id || "",
chatType: message.chat_type || "",
messageType: message.message_type || "",
openId: sender.open_id || "",
unionId: sender.union_id || "",
userId: sender.user_id || ""
};
}
export function isAllowed(identity, allowlist, allowUnlisted = false) {
if (allowUnlisted) return true;
const allowed = new Set(allowlist);
return [identity.chatId, identity.openId, identity.unionId, identity.userId]
.filter(Boolean)
.some((id) => allowed.has(id));
}
export function pairingRefusalText(identity) {
return [
"This chat is not in DEEPSEEK_CHAT_ALLOWLIST.",
`chat_id=${identity.chatId}`,
identity.openId ? `open_id=${identity.openId}` : "",
identity.unionId ? `union_id=${identity.unionId}` : "",
identity.userId ? `user_id=${identity.userId}` : ""
]
.filter(Boolean)
.join("\n");
}
export function stripGroupPrefix(text, { chatType, requirePrefix, prefix }) {
const trimmed = String(text || "").trim();
if (!trimmed) return { accepted: false, text: "" };
if (!requirePrefix || chatType === "p2p") {
return { accepted: true, text: trimmed };
}
const marker = prefix || "/ds";
if (trimmed === marker) return { accepted: true, text: "/help" };
if (trimmed.startsWith(`${marker} `)) {
return { accepted: true, text: trimmed.slice(marker.length).trim() };
}
return { accepted: false, text: "" };
}
export function parseCommand(text) {
const trimmed = String(text || "").trim();
if (!trimmed.startsWith("/")) return { name: "prompt", args: trimmed };
const [head, ...rest] = trimmed.split(/\s+/);
return {
name: head.slice(1).toLowerCase(),
args: rest.join(" ").trim()
};
}
export function parseApprovalDecisionArgs(args) {
const parts = String(args || "")
.split(/\s+/)
.filter(Boolean);
return {
approvalId: parts[0] || "",
remember: parts.slice(1).includes("remember")
};
}
export function commandAction(command) {
switch (command.name) {
case "help":
return { kind: "help" };
case "status":
return { kind: "status" };
case "threads":
return { kind: "threads" };
case "new":
return { kind: "new_thread" };
case "resume":
return { kind: "resume", threadId: command.args };
case "interrupt":
return { kind: "interrupt" };
case "compact":
return { kind: "compact" };
case "allow":
return { kind: "approval", decision: "allow", ...parseApprovalDecisionArgs(command.args) };
case "deny":
return { kind: "approval", decision: "deny", ...parseApprovalDecisionArgs(command.args) };
case "prompt":
return { kind: "prompt", prompt: command.args };
default:
return {
kind: "prompt",
prompt: `/${command.name}${command.args ? ` ${command.args}` : ""}`
};
}
}
export function splitMessage(text, maxChars = 3500) {
const value = String(text || "");
if (value.length <= maxChars) return value ? [value] : [];
const chunks = [];
let cursor = 0;
while (cursor < value.length) {
chunks.push(value.slice(cursor, cursor + maxChars));
cursor += maxChars;
}
return chunks;
}
export function compactRuntimeError(status, body) {
const message =
body?.error?.message ||
body?.message ||
(typeof body === "string" ? body : JSON.stringify(body));
return `Runtime API request failed (${status}): ${message}`;
}
export function latestRunningTurn(detail) {
const turns = Array.isArray(detail?.turns) ? detail.turns : [];
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (["queued", "in_progress"].includes(turn?.status)) return turn;
}
return null;
}
export function activeTurnBlock(detail, state = {}) {
const runningTurn = latestRunningTurn(detail);
if (!runningTurn) return null;
return {
turnId: runningTurn.id || state.activeTurnId || "",
message: `Thread already has active turn ${
runningTurn.id || state.activeTurnId || "(unknown)"
}. Wait for it to finish or send /interrupt.`
};
}
export function validateBridgeConfig(env, options = {}) {
const runtimeEnv = options.runtimeEnv || null;
const workspaceRoot = options.workspaceRoot || "";
const errors = [];
const warnings = [];
const info = [];
const add = (list, code, message) => list.push({ code, message });
for (const key of [
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
"DEEPSEEK_RUNTIME_URL",
"DEEPSEEK_RUNTIME_TOKEN",
"DEEPSEEK_WORKSPACE",
"FEISHU_THREAD_MAP_PATH"
]) {
const value = cleanEnvValue(env[key]);
if (!value) {
add(errors, "missing_required", `${key} is required`);
} else if (isPlaceholderValue(value)) {
add(errors, "placeholder_value", `${key} still contains a placeholder value`);
}
}
const domain = cleanEnvValue(env.FEISHU_DOMAIN || "feishu").toLowerCase();
if (!["feishu", "lark"].includes(domain) && !/^https:\/\/open\./.test(domain)) {
add(errors, "invalid_domain", "FEISHU_DOMAIN must be feishu, lark, or an https://open.* URL");
}
const runtimeUrl = cleanEnvValue(env.DEEPSEEK_RUNTIME_URL || "http://127.0.0.1:7878");
try {
const parsed = new URL(runtimeUrl);
const localHosts = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
if (!["http:", "https:"].includes(parsed.protocol)) {
add(errors, "invalid_runtime_url", "DEEPSEEK_RUNTIME_URL must use http or https");
}
if (!localHosts.has(parsed.hostname)) {
add(errors, "remote_runtime_url", "DEEPSEEK_RUNTIME_URL must point at localhost on Lighthouse");
}
} catch {
add(errors, "invalid_runtime_url", "DEEPSEEK_RUNTIME_URL is not a valid URL");
}
const workspace = cleanEnvValue(env.DEEPSEEK_WORKSPACE);
if (workspace && !workspace.startsWith("/")) {
add(errors, "relative_workspace", "DEEPSEEK_WORKSPACE must be an absolute path");
}
if (
workspace &&
workspaceRoot &&
workspace !== workspaceRoot &&
!workspace.startsWith(`${workspaceRoot}/`)
) {
add(warnings, "workspace_root", `DEEPSEEK_WORKSPACE is outside ${workspaceRoot}`);
}
const threadMapPath = cleanEnvValue(env.FEISHU_THREAD_MAP_PATH);
if (threadMapPath && !threadMapPath.startsWith("/")) {
add(errors, "relative_thread_map", "FEISHU_THREAD_MAP_PATH must be an absolute path");
}
const allowGroups = parseBool(env.FEISHU_ALLOW_GROUPS, false);
const requirePrefix = parseBool(env.FEISHU_REQUIRE_PREFIX_IN_GROUP, true);
const allowUnlisted = parseBool(env.DEEPSEEK_ALLOW_UNLISTED, false);
const allowlist = parseList(env.DEEPSEEK_CHAT_ALLOWLIST);
if (!allowlist.length && allowUnlisted) {
add(warnings, "pairing_mode_open", "DEEPSEEK_ALLOW_UNLISTED=true leaves first-pairing mode open");
} else if (!allowlist.length) {
add(warnings, "not_paired", "DEEPSEEK_CHAT_ALLOWLIST is empty; all chats will be refused");
}
if (allowGroups && allowUnlisted) {
add(errors, "open_group_control", "Group control cannot be enabled while unlisted chats are allowed");
}
if (allowGroups && !requirePrefix) {
add(warnings, "group_without_prefix", "Group control is enabled without requiring FEISHU_GROUP_PREFIX");
}
if (!allowGroups) {
add(info, "dm_only", "Direct-message control is enabled; group chats are disabled");
}
const maxReplyChars = Number(env.FEISHU_MAX_REPLY_CHARS || 3500);
if (!Number.isFinite(maxReplyChars) || maxReplyChars < 100) {
add(errors, "invalid_max_reply_chars", "FEISHU_MAX_REPLY_CHARS must be at least 100");
}
const turnTimeoutMs = Number(env.DEEPSEEK_TURN_TIMEOUT_MS || 900000);
if (!Number.isFinite(turnTimeoutMs) || turnTimeoutMs < 1000) {
add(errors, "invalid_turn_timeout", "DEEPSEEK_TURN_TIMEOUT_MS must be at least 1000");
}
if (runtimeEnv) {
const runtimeToken = cleanEnvValue(runtimeEnv.DEEPSEEK_RUNTIME_TOKEN);
const bridgeToken = cleanEnvValue(env.DEEPSEEK_RUNTIME_TOKEN);
if (!runtimeToken) {
add(errors, "missing_runtime_token", "runtime.env is missing DEEPSEEK_RUNTIME_TOKEN");
} else if (isPlaceholderValue(runtimeToken)) {
add(errors, "placeholder_runtime_token", "runtime.env DEEPSEEK_RUNTIME_TOKEN is still a placeholder");
} else if (bridgeToken && bridgeToken !== runtimeToken) {
add(errors, "token_mismatch", "Runtime and bridge DEEPSEEK_RUNTIME_TOKEN values do not match");
}
const apiKey = cleanEnvValue(runtimeEnv.DEEPSEEK_API_KEY);
if (!apiKey) {
add(warnings, "missing_api_key", "runtime.env is missing DEEPSEEK_API_KEY");
} else if (isPlaceholderValue(apiKey)) {
add(warnings, "placeholder_api_key", "runtime.env DEEPSEEK_API_KEY is still a placeholder");
}
const runtimePort = Number(runtimeEnv.DEEPSEEK_RUNTIME_PORT || 7878);
if (!Number.isInteger(runtimePort) || runtimePort <= 0 || runtimePort > 65535) {
add(errors, "invalid_runtime_port", "DEEPSEEK_RUNTIME_PORT must be a valid TCP port");
}
}
return {
ok: errors.length === 0,
errors,
warnings,
info
};
}
export function formatValidationReport(result) {
const lines = ["Feishu bridge config validation"];
for (const item of result.errors) lines.push(`[fail] ${item.message}`);
for (const item of result.warnings) lines.push(`[warn] ${item.message}`);
for (const item of result.info) lines.push(`[info] ${item.message}`);
if (result.ok) lines.push("[ok] No blocking config errors found");
return lines.join("\n");
}
export function helpText() {
return [
"DeepSeek phone bridge commands:",
"/help - show this help",
"/status - runtime and workspace status",
"/threads - recent runtime threads",
"/new - create a new thread for this chat",
"/resume <thread_id> - bind this chat to an existing thread",
"/interrupt - interrupt the active turn",
"/compact - compact the current thread",
"/allow <approval_id> [remember] - approve a pending tool call",
"/deny <approval_id> - deny a pending tool call",
"",
"Anything else is sent as a DeepSeek prompt."
].join("\n");
}
@@ -0,0 +1,205 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
activeTurnBlock,
commandAction,
isAllowed,
pairingRefusalText,
parseApprovalDecisionArgs,
parseBool,
parseEnvText,
parseCommand,
parseList,
parseTextContent,
splitMessage,
stripGroupPrefix,
validateBridgeConfig
} from "../src/lib.mjs";
test("parseList trims empty values", () => {
assert.deepEqual(parseList(" oc_1, ou_2 ,, "), ["oc_1", "ou_2"]);
});
test("parseBool accepts common truthy values", () => {
assert.equal(parseBool("yes"), true);
assert.equal(parseBool("0", true), false);
assert.equal(parseBool(undefined, true), true);
});
test("parseTextContent reads Feishu JSON text content", () => {
assert.equal(parseTextContent(JSON.stringify({ text: "hello" })), "hello");
});
test("parseEnvText handles comments, export, and quoted values", () => {
assert.deepEqual(
parseEnvText(`
# ignored
export FEISHU_DOMAIN="lark"
DEEPSEEK_WORKSPACE='/opt/whalebro'
`),
{
FEISHU_DOMAIN: "lark",
DEEPSEEK_WORKSPACE: "/opt/whalebro"
}
);
});
test("stripGroupPrefix requires prefix in group chats", () => {
assert.deepEqual(
stripGroupPrefix("/ds inspect this", {
chatType: "group",
requirePrefix: true,
prefix: "/ds"
}),
{ accepted: true, text: "inspect this" }
);
assert.equal(
stripGroupPrefix("inspect this", {
chatType: "group",
requirePrefix: true,
prefix: "/ds"
}).accepted,
false
);
});
test("stripGroupPrefix accepts DM text without group prefix", () => {
assert.deepEqual(
stripGroupPrefix("inspect this", {
chatType: "p2p",
requirePrefix: true,
prefix: "/ds"
}),
{ accepted: true, text: "inspect this" }
);
});
test("parseCommand distinguishes prompts and slash commands", () => {
assert.deepEqual(parseCommand("hello"), { name: "prompt", args: "hello" });
assert.deepEqual(parseCommand("/allow abc remember"), {
name: "allow",
args: "abc remember"
});
});
test("commandAction maps bridge commands and falls back to prompts", () => {
assert.deepEqual(commandAction(parseCommand("/status")), { kind: "status" });
assert.deepEqual(commandAction(parseCommand("/resume thread-1")), {
kind: "resume",
threadId: "thread-1"
});
assert.deepEqual(commandAction(parseCommand("/unknown value")), {
kind: "prompt",
prompt: "/unknown value"
});
});
test("parseApprovalDecisionArgs extracts remember flag", () => {
assert.deepEqual(parseApprovalDecisionArgs("ap_123 remember"), {
approvalId: "ap_123",
remember: true
});
assert.deepEqual(parseApprovalDecisionArgs(""), { approvalId: "", remember: false });
});
test("isAllowed checks chat and user identifiers", () => {
assert.equal(
isAllowed({ chatId: "oc_x", openId: "ou_y" }, ["ou_y"], false),
true
);
assert.equal(isAllowed({ chatId: "oc_x" }, [], false), false);
assert.equal(isAllowed({ chatId: "oc_x" }, [], true), true);
});
test("pairingRefusalText includes allowlist identifiers", () => {
const body = pairingRefusalText({
chatId: "oc_chat",
openId: "ou_user",
unionId: "on_union",
userId: "u_user"
});
assert.match(body, /chat_id=oc_chat/);
assert.match(body, /open_id=ou_user/);
assert.match(body, /union_id=on_union/);
assert.match(body, /user_id=u_user/);
});
test("activeTurnBlock reports active queued or in-progress turn", () => {
assert.equal(activeTurnBlock({ turns: [{ id: "done", status: "completed" }] }), null);
assert.deepEqual(
activeTurnBlock({
turns: [
{ id: "old", status: "completed" },
{ id: "turn-2", status: "in_progress" }
]
}),
{
turnId: "turn-2",
message: "Thread already has active turn turn-2. Wait for it to finish or send /interrupt."
}
);
});
test("splitMessage chunks long text", () => {
assert.deepEqual(splitMessage("abcdef", 2), ["ab", "cd", "ef"]);
});
test("validateBridgeConfig accepts locked-down whalebro DM config", () => {
const result = validateBridgeConfig(
{
FEISHU_APP_ID: "cli_valid",
FEISHU_APP_SECRET: "secret",
FEISHU_DOMAIN: "lark",
DEEPSEEK_RUNTIME_URL: "http://127.0.0.1:7878",
DEEPSEEK_RUNTIME_TOKEN: "token-a",
DEEPSEEK_WORKSPACE: "/opt/whalebro",
DEEPSEEK_CHAT_ALLOWLIST: "oc_allowed",
DEEPSEEK_ALLOW_UNLISTED: "false",
FEISHU_THREAD_MAP_PATH: "/var/lib/deepseek-feishu-bridge/thread-map.json",
FEISHU_ALLOW_GROUPS: "false",
FEISHU_REQUIRE_PREFIX_IN_GROUP: "true"
},
{
workspaceRoot: "/opt/whalebro",
runtimeEnv: {
DEEPSEEK_RUNTIME_TOKEN: "token-a",
DEEPSEEK_API_KEY: "sk-valid",
DEEPSEEK_RUNTIME_PORT: "7878"
}
}
);
assert.equal(result.ok, true);
assert.equal(result.errors.length, 0);
});
test("validateBridgeConfig rejects unsafe group pairing and token mismatch", () => {
const result = validateBridgeConfig(
{
FEISHU_APP_ID: "cli_valid",
FEISHU_APP_SECRET: "secret",
FEISHU_DOMAIN: "feishu",
DEEPSEEK_RUNTIME_URL: "http://127.0.0.1:7878",
DEEPSEEK_RUNTIME_TOKEN: "bridge-token",
DEEPSEEK_WORKSPACE: "/opt/whalebro",
DEEPSEEK_ALLOW_UNLISTED: "true",
FEISHU_THREAD_MAP_PATH: "/var/lib/deepseek-feishu-bridge/thread-map.json",
FEISHU_ALLOW_GROUPS: "true",
FEISHU_REQUIRE_PREFIX_IN_GROUP: "false"
},
{
workspaceRoot: "/opt/whalebro",
runtimeEnv: {
DEEPSEEK_RUNTIME_TOKEN: "runtime-token",
DEEPSEEK_API_KEY: "replace-with-deepseek-platform-key"
}
}
);
assert.equal(result.ok, false);
assert.match(
result.errors.map((item) => item.code).join(","),
/open_group_control/
);
assert.match(result.errors.map((item) => item.code).join(","), /token_mismatch/);
assert.match(result.warnings.map((item) => item.code).join(","), /group_without_prefix/);
});