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:
@@ -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
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user