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
+88
View File
@@ -0,0 +1,88 @@
# CNB is a one-way mirror from GitHub. Keep this file source-controlled here;
# CNB-side edits will be overwritten by the GitHub -> CNB sync workflow.
main:
push:
- docker:
image: node:22-bookworm
stages:
- name: feishu bridge tests
script: |
set -euo pipefail
cd integrations/feishu-bridge
npm ci
npm run check
npm test
- docker:
image: rust:1.88-bookworm
stages:
- name: release version check
script: |
set -euo pipefail
apt-get update
apt-get install -y git libdbus-1-dev nodejs pkg-config
./scripts/release/check-versions.sh
$:
tag_push:
- docker:
image: rust:1.88-bookworm
stages:
- name: build linux x64 release assets
script: |
set -euo pipefail
apt-get update
apt-get install -y git libdbus-1-dev nodejs pkg-config
./scripts/release/check-versions.sh
cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui
mkdir -p target/cnb-release
cp target/release/deepseek target/cnb-release/deepseek-linux-x64
cp target/release/deepseek-tui target/cnb-release/deepseek-tui-linux-x64
strip target/cnb-release/deepseek-linux-x64 target/cnb-release/deepseek-tui-linux-x64 || true
(
cd target/cnb-release
sha256sum deepseek-linux-x64 deepseek-tui-linux-x64 \
> deepseek-artifacts-sha256.txt
)
tag_name="${CNB_BRANCH:-}"
if [ -z "$tag_name" ]; then
tag_name="$(git describe --tags --exact-match 2>/dev/null || true)"
fi
version="${tag_name#v}"
commit_sha="${CNB_COMMIT:-$(git rev-parse HEAD)}"
{
echo "# ${tag_name:-CNB release}"
echo
awk -v version="${version}" '
index($0, "## [" version "]") == 1 { in_section = 1; next }
in_section && /^## \[/ { exit }
in_section { print }
' CHANGELOG.md
echo
echo "Built by CNB from ${commit_sha}."
echo
echo "Assets:"
echo "- deepseek-linux-x64"
echo "- deepseek-tui-linux-x64"
echo "- deepseek-artifacts-sha256.txt"
} > target/cnb-release/CNB_RELEASE.md
- name: create cnb release
type: git:release
options:
descriptionFromFile: target/cnb-release/CNB_RELEASE.md
latest: true
- name: upload linux x64 release assets
image: cnbcool/attachments:latest
settings:
attachments:
- target/cnb-release/deepseek-linux-x64
- target/cnb-release/deepseek-tui-linux-x64
- target/cnb-release/deepseek-artifacts-sha256.txt
+13 -8
View File
@@ -5,9 +5,10 @@ name: Sync to CNB
# releases from the Tencent-hosted mirror.
#
# Triggers:
# * push to main → mirrors that commit to CNB main
# * tag matching v* → mirrors that tag to CNB
# * workflow_dispatch → manual fallback if either of the above fails
# * push to main → mirrors that commit to CNB main
# * tag matching v* → mirrors that tag to CNB
# * Tencent release branches → mirrors Feishu/Lighthouse setup branches
# * workflow_dispatch → manual fallback if any of the above fails
#
# Why the rewrite (v0.8.31):
# The previous implementation used the opaque tencentcom/git-sync Docker
@@ -22,7 +23,10 @@ name: Sync to CNB
on:
push:
branches: [main]
branches:
- main
- 'work/v*-feishu-*'
- 'work/v*-lighthouse*'
tags: ['v*']
workflow_dispatch: {}
@@ -105,9 +109,10 @@ jobs:
# was actually behind GitHub.
push_with_retry "main" HEAD:refs/heads/main --force
else
# workflow_dispatch from a non-main branch — push that branch
# too, but never force. Useful for testing the mirror against
# a feature branch before merging.
# Tencent release-candidate branches are first-class CNB
# sources for Lighthouse/Feishu bootstrap. Mirror the triggering
# branch exactly so the CNB clone path stays the default even
# before the branch has merged to main or become a release tag.
BRANCH="${GITHUB_REF#refs/heads/}"
push_with_retry "branch ${BRANCH}" "HEAD:refs/heads/${BRANCH}"
push_with_retry "branch ${BRANCH}" "HEAD:refs/heads/${BRANCH}" --force
fi
+27 -1
View File
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Tencent Lighthouse + Feishu/Lark bridge setup.** Added a `/opt/whalebro`
Lighthouse runbook, systemd deploy assets, a long-connection Feishu/Lark
bridge, a bridge config validator, and a VPS doctor for runtime, Node,
binaries, env, systemd, and localhost health checks.
- **Tencent Cloud remote-first onboarding.** Documented the CNB + Lighthouse +
Feishu/Lark + optional EdgeOne teaching path and added non-active CNB deploy
templates for a future Lighthouse deploy button. Feishu/Lighthouse branches
are now mirrored to CNB for Tencent-first bootstrap.
### Fixed
- **Feishu/Lark bridge dependency installs are locked and audited.** The
bridge now ships a package lock, installs with `npm ci` on Lighthouse when
available, and overrides the Lark SDK's transitive `axios` dependency to a
patched line.
- **China-friendly update fallback.** `deepseek update` now supports mirrored
release assets through `DEEPSEEK_TUI_RELEASE_BASE_URL` plus
`DEEPSEEK_TUI_VERSION`, and its network-failure hints point users behind
GitHub-blocking networks to the CNB `cargo install --git` path for both
shipped binaries.
- **CNB is the default Tencent release-candidate mirror.** The CNB sync
workflow now mirrors Feishu/Lighthouse release branches, so Tencent
Lighthouse bootstrap can use CNB before the release branch merges.
### Changed
- **Bing is the default `web_search` backend.** DuckDuckGo remains selectable
@@ -4135,7 +4161,7 @@ Welcome — and thank you.
- Hooks system and config profiles
- Example skills and launch assets
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.35...HEAD
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.36...HEAD
[0.8.36]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.35...v0.8.36
[0.8.35]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.34...v0.8.35
[0.8.34]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.33...v0.8.34
+11
View File
@@ -138,6 +138,17 @@ keys take precedence over the keyring and environment and are easier to rotate.
> To rotate or remove a saved key: `deepseek auth clear --provider deepseek`.
### Tencent Cloud / CNB Remote-First Path
For an always-on workspace you can control from a phone, use the Tencent-native
path: CNB mirror/source, Tencent Lighthouse HK, a Feishu/Lark long-connection
bridge, and optional EdgeOne for a deliberate public HTTPS edge. The runtime API
stays bound to localhost; EdgeOne is not used to expose `/v1/*`.
Start with [docs/TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md),
then use [docs/TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) for the
server runbook.
### Auto Mode
Use `deepseek --model auto` or `/model auto` when you want DeepSeek TUI to decide how much model and reasoning power a turn needs.
+10
View File
@@ -119,6 +119,16 @@ deepseek doctor # 验证安装
> 轮换或移除密钥:`deepseek auth clear --provider deepseek`
### 腾讯云 / CNB 远程优先路径
如果你想要一个长期在线、可从手机控制的工作区,推荐使用腾讯云原生路径:
CNB 镜像/源码,腾讯云 Lighthouse 香港实例,飞书/Lark 长连接桥接,
以及可选的 EdgeOne 公网 HTTPS 边缘。运行时 API 必须绑定在 localhost
不要通过 EdgeOne 暴露 `/v1/*`
先看 [docs/TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md)
再按 [docs/TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) 配置服务器。
### Auto 模式
使用 `deepseek --model auto``/model auto` 让 DeepSeek TUI 自行决定每轮需要多少模型和推理能力。
+155 -5
View File
@@ -13,6 +13,11 @@ use std::io::Write;
const CHECKSUM_MANIFEST_ASSET: &str = "deepseek-artifacts-sha256.txt";
const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Hmbown/DeepSeek-TUI/releases/latest";
const CNB_REPO_URL: &str = "https://cnb.cool/deepseek-tui.com/DeepSeek-TUI";
const RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL";
const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL";
const UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION";
const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION";
const UPDATE_USER_AGENT: &str = "deepseek-tui-updater";
/// Run the self-update workflow.
@@ -25,7 +30,7 @@ pub fn run_update() -> Result<()> {
println!("Current binary: {}", current_exe.display());
// Step 1: Fetch latest release metadata
let release = fetch_latest_release()?;
let release = fetch_latest_release().with_context(update_network_fallback_hint)?;
let latest_tag = &release.tag_name;
println!("Latest release: {latest_tag}");
@@ -33,8 +38,14 @@ pub fn run_update() -> Result<()> {
let checksum_manifest = match select_checksum_manifest_asset(&release) {
Some(checksum_asset) => {
println!("Downloading {}...", checksum_asset.name);
let checksum_bytes = download_url(&checksum_asset.browser_download_url)
.with_context(|| format!("failed to download {}", checksum_asset.name))?;
let checksum_bytes =
download_url(&checksum_asset.browser_download_url).with_context(|| {
format!(
"failed to download {}\n{}",
checksum_asset.name,
update_network_fallback_hint()
)
})?;
let checksum_text = std::str::from_utf8(&checksum_bytes)
.with_context(|| format!("{} is not valid UTF-8", checksum_asset.name))?;
Some(parse_checksum_manifest(checksum_text)?)
@@ -63,8 +74,13 @@ pub fn run_update() -> Result<()> {
})?;
println!("Downloading {}...", asset.name);
let bytes = download_url(&asset.browser_download_url)
.with_context(|| format!("failed to download {}", asset.name))?;
let bytes = download_url(&asset.browser_download_url).with_context(|| {
format!(
"failed to download {}\n{}",
asset.name,
update_network_fallback_hint()
)
})?;
if let Some(checksums) = &checksum_manifest {
let expected = checksums
@@ -176,6 +192,15 @@ fn release_asset_stem_for_prefix(prefix: &str, os: &str, rust_arch: &str) -> Str
format!("{prefix}-{os}-{arch}")
}
fn release_asset_name_for_prefix(prefix: &str, os: &str, rust_arch: &str) -> String {
let stem = release_asset_stem_for_prefix(prefix, os, rust_arch);
if os == "windows" {
format!("{stem}.exe")
} else {
stem
}
}
#[cfg(test)]
fn release_asset_stem_for(current_exe: &Path, os: &str, rust_arch: &str) -> String {
let prefix = binary_prefix_for_exe(current_exe);
@@ -272,9 +297,74 @@ fn update_http_client() -> Result<reqwest::blocking::Client> {
/// Fetch the latest release metadata from GitHub.
fn fetch_latest_release() -> Result<Release> {
if let Some(base_url) = release_base_url_from_env() {
let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into());
return Ok(release_from_mirror_base_url(
&base_url,
&version,
std::env::consts::OS,
std::env::consts::ARCH,
));
}
fetch_latest_release_from_url(LATEST_RELEASE_URL)
}
fn release_base_url_from_env() -> Option<String> {
std::env::var(RELEASE_BASE_URL_ENV)
.ok()
.or_else(|| std::env::var(LEGACY_RELEASE_BASE_URL_ENV).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn update_version_from_env() -> Option<String> {
std::env::var(UPDATE_VERSION_ENV)
.ok()
.or_else(|| std::env::var(LEGACY_UPDATE_VERSION_ENV).ok())
.map(|value| value.trim().trim_start_matches('v').to_string())
.filter(|value| !value.is_empty())
}
fn release_from_mirror_base_url(
base_url: &str,
version: &str,
os: &str,
rust_arch: &str,
) -> Release {
let tag_name = format!("v{}", version.trim_start_matches('v'));
let mut assets = vec![Asset {
name: CHECKSUM_MANIFEST_ASSET.to_string(),
browser_download_url: mirror_asset_url(base_url, CHECKSUM_MANIFEST_ASSET),
}];
for prefix in ["deepseek", "deepseek-tui"] {
let name = release_asset_name_for_prefix(prefix, os, rust_arch);
assets.push(Asset {
browser_download_url: mirror_asset_url(base_url, &name),
name,
});
}
Release { tag_name, assets }
}
fn mirror_asset_url(base_url: &str, asset_name: &str) -> String {
format!("{}/{}", base_url.trim_end_matches('/'), asset_name)
}
fn update_network_fallback_hint() -> String {
format!(
"GitHub release downloads may be blocked or slow on this network.\n\
For mainland China, use one of these fallback paths:\n\
1. Source build from the CNB mirror, installing both shipped binaries:\n\
cargo install --git {CNB_REPO_URL} --tag vX.Y.Z deepseek-tui-cli --locked --force\n\
cargo install --git {CNB_REPO_URL} --tag vX.Y.Z deepseek-tui --locked --force\n\
2. Use a binary asset mirror:\n\
{RELEASE_BASE_URL_ENV}=https://<mirror>/<release-assets>/ {UPDATE_VERSION_ENV}=X.Y.Z deepseek update\n\
The mirror directory must contain {CHECKSUM_MANIFEST_ASSET} and the platform binaries."
)
}
fn fetch_latest_release_from_url(url: &str) -> Result<Release> {
let client = update_http_client()?;
let response = client
@@ -684,6 +774,66 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *deepseek-wind
assert_eq!(asset.name, "deepseek-tui-macos-arm64");
}
#[test]
fn mirror_release_uses_base_url_and_platform_assets() {
let release = release_from_mirror_base_url(
"https://mirror.example/releases/v0.8.36/",
"0.8.36",
"linux",
"x86_64",
);
assert_eq!(release.tag_name, "v0.8.36");
assert_eq!(release.assets[0].name, CHECKSUM_MANIFEST_ASSET);
assert_eq!(
release.assets[0].browser_download_url,
"https://mirror.example/releases/v0.8.36/deepseek-artifacts-sha256.txt"
);
let dispatcher =
select_platform_asset(&release, "deepseek-linux-x64").expect("dispatcher asset");
assert_eq!(
dispatcher.browser_download_url,
"https://mirror.example/releases/v0.8.36/deepseek-linux-x64"
);
let tui = select_platform_asset(&release, "deepseek-tui-linux-x64").expect("tui asset");
assert_eq!(
tui.browser_download_url,
"https://mirror.example/releases/v0.8.36/deepseek-tui-linux-x64"
);
}
#[test]
fn mirror_release_uses_windows_exe_asset_names() {
let release = release_from_mirror_base_url(
"https://mirror.example/releases/v0.8.36",
"v0.8.36",
"windows",
"x86_64",
);
assert_eq!(release.tag_name, "v0.8.36");
assert!(
select_platform_asset(&release, "deepseek-windows-x64")
.is_some_and(|asset| asset.name == "deepseek-windows-x64.exe")
);
assert!(
select_platform_asset(&release, "deepseek-tui-windows-x64")
.is_some_and(|asset| asset.name == "deepseek-tui-windows-x64.exe")
);
}
#[test]
fn update_fallback_hint_points_china_users_to_cnb_and_asset_mirrors() {
let hint = update_network_fallback_hint();
assert!(hint.contains(CNB_REPO_URL), "{hint}");
assert!(hint.contains(RELEASE_BASE_URL_ENV), "{hint}");
assert!(hint.contains(UPDATE_VERSION_ENV), "{hint}");
assert!(hint.contains("deepseek-tui-cli"), "{hint}");
assert!(hint.contains("deepseek-tui --locked"), "{hint}");
}
fn serve_http_once(
status: &'static str,
content_type: &'static str,
+27 -1
View File
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Tencent Lighthouse + Feishu/Lark bridge setup.** Added a `/opt/whalebro`
Lighthouse runbook, systemd deploy assets, a long-connection Feishu/Lark
bridge, a bridge config validator, and a VPS doctor for runtime, Node,
binaries, env, systemd, and localhost health checks.
- **Tencent Cloud remote-first onboarding.** Documented the CNB + Lighthouse +
Feishu/Lark + optional EdgeOne teaching path and added non-active CNB deploy
templates for a future Lighthouse deploy button. Feishu/Lighthouse branches
are now mirrored to CNB for Tencent-first bootstrap.
### Fixed
- **Feishu/Lark bridge dependency installs are locked and audited.** The
bridge now ships a package lock, installs with `npm ci` on Lighthouse when
available, and overrides the Lark SDK's transitive `axios` dependency to a
patched line.
- **China-friendly update fallback.** `deepseek update` now supports mirrored
release assets through `DEEPSEEK_TUI_RELEASE_BASE_URL` plus
`DEEPSEEK_TUI_VERSION`, and its network-failure hints point users behind
GitHub-blocking networks to the CNB `cargo install --git` path for both
shipped binaries.
- **CNB is the default Tencent release-candidate mirror.** The CNB sync
workflow now mirrors Feishu/Lighthouse release branches, so Tencent
Lighthouse bootstrap can use CNB before the release branch merges.
### Changed
- **Bing is the default `web_search` backend.** DuckDuckGo remains selectable
@@ -4135,7 +4161,7 @@ Welcome — and thank you.
- Hooks system and config profiles
- Example skills and launch assets
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.35...HEAD
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.36...HEAD
[0.8.36]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.35...v0.8.36
[0.8.35]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.34...v0.8.35
[0.8.34]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.8.33...v0.8.34
+52
View File
@@ -0,0 +1,52 @@
# CNB Deploy Templates
The root `.cnb.yml` is intentionally source-controlled in GitHub because CNB is
a one-way mirror from GitHub. Do not add or edit `.cnb.yml` only on the CNB
side; the next GitHub sync will overwrite it.
The active root `.cnb.yml` does two things:
- runs Feishu bridge and version-drift checks when CNB receives `main`;
- builds Linux x64 release assets from `v*` tags, creates the CNB release, and
uploads `deepseek-linux-x64`, `deepseek-tui-linux-x64`, and
`deepseek-artifacts-sha256.txt`.
The files in this directory are retained as deploy-button templates for Tencent
Lighthouse. Copy only the deploy environment file after the Lighthouse instance
is already working manually:
```bash
mkdir -p .cnb
cp deploy/tencent-lighthouse/cnb/tag_deploy.yml.example .cnb/tag_deploy.yml
```
If you also need to customize `.cnb.yml`, edit the root file in GitHub and let
the one-way mirror carry it to CNB.
## Required CNB Secrets
Configure these as protected CNB environment variables or secrets:
- `LIGHTHOUSE_HOST`: public IP or DNS name of the Lighthouse instance
- `LIGHTHOUSE_SSH_TARGET`: SSH target, for example `ubuntu@203.0.113.10`
- `LIGHTHOUSE_SSH_PRIVATE_KEY`: private deploy key allowed to update the server
- `DEEPSEEK_REPO_BRANCH`: branch or tag to deploy, for example `main`
Optional:
- `DEEPSEEK_REPO_URL`: defaults to the CNB mirror URL
- `LIGHTHOUSE_SSH_PORT`: defaults to `22`
The server side should already have `/opt/whalebro/deepseek-tui`,
`/etc/deepseek/runtime.env`, `/etc/deepseek/feishu-bridge.env`, and the
systemd services from `docs/TENCENT_LIGHTHOUSE_HK.md`.
## Safety Notes
- Do not store Feishu App Secret or DeepSeek API keys in CNB. They belong in
`/etc/deepseek/*.env` on Lighthouse.
- Do not expose `127.0.0.1:7878` through EdgeOne, a security group, or a public
reverse proxy.
- Start with a manual deploy button. Automatic deploy on every `main` push is
convenient later, but it can consume CNB quota and restart the phone bridge
while a turn is active.
@@ -0,0 +1,87 @@
# Historical CNB config template for the Tencent Lighthouse remote-first path.
# The active pipeline now lives in the repository-root .cnb.yml so the GitHub
# -> CNB one-way mirror cannot overwrite CNB-only pipeline edits.
main:
push:
- docker:
image: node:22-bookworm
stages:
- name: feishu bridge tests
script: |
cd integrations/feishu-bridge
npm install
npm run check
npm test
- docker:
image: rust:1.88-bookworm
stages:
- name: release version check
script: |
./scripts/release/check-versions.sh
web_trigger_lighthouse:
- docker:
image: cnbcool/default-build-env:latest
stages:
- name: deploy to lighthouse
script: |
set -euo pipefail
: "${LIGHTHOUSE_HOST:?Set LIGHTHOUSE_HOST in CNB secrets}"
: "${LIGHTHOUSE_SSH_TARGET:?Set LIGHTHOUSE_SSH_TARGET in CNB secrets}"
: "${LIGHTHOUSE_SSH_PRIVATE_KEY:?Set LIGHTHOUSE_SSH_PRIVATE_KEY in CNB secrets}"
if ! command -v ssh >/dev/null 2>&1 || ! command -v ssh-keyscan >/dev/null 2>&1; then
apt-get update
apt-get install -y openssh-client
fi
LIGHTHOUSE_SSH_PORT="${LIGHTHOUSE_SSH_PORT:-22}"
DEEPSEEK_REPO_BRANCH="${DEEPSEEK_REPO_BRANCH:-main}"
DEEPSEEK_REPO_URL="${DEEPSEEK_REPO_URL:-https://cnb.cool/deepseek-tui.com/DeepSeek-TUI.git}"
install -m 700 -d ~/.ssh
printf '%s\n' "$LIGHTHOUSE_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p "$LIGHTHOUSE_SSH_PORT" -H "$LIGHTHOUSE_HOST" >> ~/.ssh/known_hosts
ssh -p "$LIGHTHOUSE_SSH_PORT" "$LIGHTHOUSE_SSH_TARGET" \
"DEEPSEEK_REPO_BRANCH='$DEEPSEEK_REPO_BRANCH' DEEPSEEK_REPO_URL='$DEEPSEEK_REPO_URL' bash -s" <<'REMOTE'
set -euo pipefail
if [ ! -d /opt/whalebro/deepseek-tui/.git ]; then
sudo -u deepseek git clone --branch "$DEEPSEEK_REPO_BRANCH" "$DEEPSEEK_REPO_URL" /opt/whalebro/deepseek-tui
fi
cd /opt/whalebro/deepseek-tui
if [ -n "$(sudo -u deepseek git status --porcelain)" ]; then
echo "Refusing to deploy over a dirty /opt/whalebro/deepseek-tui checkout." >&2
sudo -u deepseek git status --short
exit 1
fi
sudo -u deepseek git fetch --all --tags
if sudo -u deepseek git rev-parse --verify --quiet "refs/remotes/origin/$DEEPSEEK_REPO_BRANCH" >/dev/null; then
sudo -u deepseek git checkout -B "$DEEPSEEK_REPO_BRANCH" "origin/$DEEPSEEK_REPO_BRANCH"
elif sudo -u deepseek git rev-parse --verify --quiet "refs/tags/$DEEPSEEK_REPO_BRANCH" >/dev/null; then
sudo -u deepseek git checkout --detach "$DEEPSEEK_REPO_BRANCH"
else
sudo -u deepseek git checkout "$DEEPSEEK_REPO_BRANCH"
sudo -u deepseek git pull --ff-only
fi
sudo -iu deepseek bash -lc '
set -euo pipefail
. "$HOME/.cargo/env"
cd /opt/whalebro/deepseek-tui
cargo install --path crates/cli --locked --force
cargo install --path crates/tui --locked --force
'
sudo bash scripts/tencent-lighthouse/install-services.sh
sudo systemctl restart deepseek-runtime
sudo systemctl restart deepseek-feishu-bridge
sudo bash scripts/tencent-lighthouse/doctor.sh
REMOTE
@@ -0,0 +1,16 @@
# Example CNB deployment environment.
# Copy to .cnb/tag_deploy.yml only after the Lighthouse deploy target is ready.
environments:
- name: lighthouse-hk
description: Deploy DeepSeek TUI to Tencent Lighthouse Hong Kong.
env:
name: lighthouse-hk
button:
- name: Deploy Lighthouse
description: Update /opt/whalebro/deepseek-tui, restart services, and run the Lighthouse doctor.
event: web_trigger_lighthouse
isDefault: true
permissions:
roles:
- master
@@ -0,0 +1,21 @@
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-same-token-as-runtime-env
DEEPSEEK_WORKSPACE=/opt/whalebro
DEEPSEEK_MODEL=auto
DEEPSEEK_MODE=agent
DEEPSEEK_ALLOW_SHELL=true
DEEPSEEK_TRUST_MODE=false
DEEPSEEK_AUTO_APPROVE=false
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,5 @@
DEEPSEEK_RUNTIME_TOKEN=replace-with-long-random-token
DEEPSEEK_RUNTIME_PORT=7878
DEEPSEEK_RUNTIME_WORKERS=2
DEEPSEEK_API_KEY=replace-with-deepseek-platform-key
RUST_LOG=info
@@ -0,0 +1,21 @@
# AGENTS.md
This directory is a remote travel workspace, not a single project.
Expected layout:
- `deepseek-tui/` - canonical runtime/bridge checkout. The supported CLI is
`deepseek`; install both `crates/cli` and `crates/tui`.
- `whalescale/` - product repo. Active surface is `whalescale-desktop/`.
- `worktrees/` - remote worktrees created on this VPS.
Operational rules:
- Treat `/opt/whalebro` as the workspace root for phone-controlled work.
- Keep `deepseek serve --http` bound to `127.0.0.1`.
- Use SSH keys for Git remotes and never paste secrets into prompts, logs, or
committed files.
- Mac-only release tasks such as iOS simulator runs, `.app` packaging, DMG
verification, notarization, and Apple signing still need the local Mac.
- If a project has its own `AGENTS.md`, read it before editing inside that
project.
@@ -0,0 +1,21 @@
[Unit]
Description=DeepSeek Feishu/Lark Phone Bridge
Wants=network-online.target deepseek-runtime.service
After=network-online.target deepseek-runtime.service
[Service]
Type=simple
User=deepseek
Group=deepseek
WorkingDirectory=/opt/deepseek/bridge
EnvironmentFile=/etc/deepseek/feishu-bridge.env
ExecStart=/usr/bin/node /opt/deepseek/bridge/src/index.mjs
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/var/lib/deepseek-feishu-bridge
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,21 @@
[Unit]
Description=DeepSeek TUI Runtime API
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=deepseek
Group=deepseek
WorkingDirectory=/opt/whalebro
EnvironmentFile=/etc/deepseek/runtime.env
ExecStart=/home/deepseek/.cargo/bin/deepseek serve --http --host 127.0.0.1 --port ${DEEPSEEK_RUNTIME_PORT} --workers ${DEEPSEEK_RUNTIME_WORKERS} --auth-token ${DEEPSEEK_RUNTIME_TOKEN}
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/home/deepseek/.deepseek /opt/whalebro
[Install]
WantedBy=multi-user.target
+63 -20
View File
@@ -2,28 +2,49 @@
`cnb.cool/deepseek-tui.com/DeepSeek-TUI` is a one-way mirror of this
GitHub repository for users on networks where GitHub is slow or blocked
(primarily mainland China). The mirror receives every push to `main` and
every `v*` release tag.
(primarily mainland China). The mirror receives every push to `main`, every
`v*` release tag, and Tencent release-candidate branches used by the
Lighthouse/Feishu setup.
## How it works
The mirror is maintained by the [`Sync to CNB`](../.github/workflows/sync-cnb.yml)
GitHub Actions workflow:
- **Trigger:** `push` to `main`, `push` of any `v*` tag, or
`workflow_dispatch` for manual recovery.
- **Trigger:** `push` to `main`, `push` of any `v*` tag,
Tencent setup branches matching `work/v*-feishu-*` or
`work/v*-lighthouse*`, or `workflow_dispatch` for manual recovery.
- **Auth:** HTTPS basic auth as user `cnb` with the `CNB_GIT_TOKEN`
repository secret as the password.
- **Scope:** only the ref that triggered the run is pushed. Tag pushes
push exactly that tag. Branch pushes push only `main`
(`--force-with-lease`). Feature branches and dependabot refs are
intentionally *not* mirrored.
push exactly that tag. Branch pushes mirror `main` or an explicitly
matched Tencent setup branch. Other feature branches and dependabot refs
are intentionally *not* mirrored.
- **Concurrency:** runs are serialized via a `cnb-sync` concurrency
group so the back-to-back `main` push and tag push from
`auto-tag.yml` cannot race each other.
- **Retry:** each push is retried up to three times with linear
backoff (5s, 10s) before the workflow gives up.
CNB pipeline configuration is also source-controlled in GitHub at
[`/.cnb.yml`](../.cnb.yml). This is deliberate: the sync workflow force-mirrors
GitHub refs to CNB, so pipeline files created only on the CNB side will be
overwritten. Submit `.cnb.yml` changes through GitHub PRs and let the one-way
mirror carry them to CNB.
## CNB tag releases
When CNB receives a `v*` tag, the root `.cnb.yml` tag pipeline builds Linux x64
release assets from source and publishes a CNB release with:
- `deepseek-linux-x64`
- `deepseek-tui-linux-x64`
- `deepseek-artifacts-sha256.txt`
This gives users who can reach CNB but not GitHub a CNB-native release path.
GitHub remains the canonical full release matrix; the CNB tag pipeline is the
China-friendly Linux x64 fallback.
## Verifying the mirror after a release
After `release.yml` completes for a `vX.Y.Z` tag, the CNB mirror
@@ -114,11 +135,11 @@ expired:
```
4. Confirm the run succeeds via `gh run list --workflow=sync-cnb.yml`.
## Binary release assets
## Binary release assets and `deepseek update`
CNB is a code mirror only — it does not host binary release assets.
Users behind GitHub-blocking networks who need the prebuilt binaries
have two options:
CNB now builds Linux x64 assets for `v*` tags from the source-controlled
`.cnb.yml` pipeline. GitHub remains the canonical full release matrix. Users
behind GitHub-blocking networks should use one of these paths:
- **`cargo install`** from the CNB mirror:
```bash
@@ -128,13 +149,35 @@ have two options:
(Both binaries are required — the dispatcher and the TUI ship
separately; see `AGENTS.md` for the two-binary install rationale.)
- **`DEEPSEEK_TUI_RELEASE_BASE_URL`** environment variable, if a
third-party CDN mirror of the GitHub Release assets exists. The
npm wrapper installer in `npm/deepseek-tui/scripts/install.js`
reads this variable to redirect binary downloads. The directory
pointed to must contain `deepseek-artifacts-sha256.txt` and the
platform binaries; format matches a GitHub Release asset
directory.
- **CNB release assets** for Linux x64, when the matching CNB tag pipeline has
completed successfully. Download `deepseek-linux-x64`,
`deepseek-tui-linux-x64`, and `deepseek-artifacts-sha256.txt` from the CNB
release for `vX.Y.Z`, then verify the binaries against the manifest.
A first-party binary CDN mirror for CNB users is on the v0.8.32+
roadmap; it is not part of v0.8.31.
- **`DEEPSEEK_TUI_RELEASE_BASE_URL`** environment variable, if a
CDN mirror of release assets exists. The npm
wrapper installer and `deepseek update` read this variable to redirect
binary downloads. For `deepseek update`, also set
`DEEPSEEK_TUI_VERSION=X.Y.Z` so the updater can label the mirrored
release without contacting GitHub. The directory pointed to must contain
`deepseek-artifacts-sha256.txt` and the platform binaries; format matches
a GitHub Release asset directory.
## Tencent Cloud remote-first path
The Lighthouse + Feishu/Lark tutorial uses CNB as the Tencent-side source and
automation lane. For a stable install, clone `main` or a release tag from:
```bash
https://cnb.cool/deepseek-tui.com/DeepSeek-TUI.git
```
The mirror receives `main`, release tags, and the Tencent setup branch patterns
used by the Lighthouse/Feishu tutorial. Those CNB refs are the default source
for Tencent-side bootstrap; GitHub is the fallback when the CNB workflow or
credentials are unhealthy.
CNB deploy-button examples live in `deploy/tencent-lighthouse/cnb/`. They are
not active until copied into `.cnb.yml` and `.cnb/tag_deploy.yml`, because live
deploy jobs require a Lighthouse deploy key, target host, and explicit CNB
quota/billing policy.
+81
View File
@@ -0,0 +1,81 @@
# Feishu Lighthouse v0.8.37 Plan
Goal: make Feishu/Lark on Tencent Lighthouse a supported remote-control path
for `deepseek serve --http`.
## Release Shape
- The public teaching path is Tencent-native: CNB source/build/deploy,
Lighthouse runtime, Feishu/Lark phone control, and optional EdgeOne for a
deliberate public HTTPS edge.
- `deepseek serve --http` runs as a localhost systemd service on the VPS.
- `integrations/feishu-bridge` receives Feishu/Lark messages over long
connection mode and calls the runtime API with a bearer token.
- `/opt/whalebro` is the remote workspace root.
- `/opt/whalebro/deepseek-tui` is required.
- `/opt/whalebro/whalescale` is available when product work is needed.
- Direct-message control is the default phone workflow.
## Current Foundation
- Bridge source: `integrations/feishu-bridge/`
- Tencent deploy assets: `deploy/tencent-lighthouse/`
- VPS scripts: `scripts/tencent-lighthouse/`
- Config validator: `integrations/feishu-bridge/scripts/validate-config.mjs`
- VPS doctor: `scripts/tencent-lighthouse/doctor.sh`
- Remote-first tutorial: `docs/TENCENT_CLOUD_REMOTE_FIRST.md`
- CNB deploy templates: `deploy/tencent-lighthouse/cnb/`
- Runbook: `docs/TENCENT_LIGHTHOUSE_HK.md`
- Computer Use handoff: `docs/TENCENT_LIGHTHOUSE_HANDOFF_PROMPT.md`
## v0.8.37 Work
1. Create a release branch for this lane, then update the runbook branch value
once it is pushed.
2. Add a Lighthouse doctor script that checks Ubuntu packages, Node version,
installed `deepseek` binaries, systemd unit files, env files, runtime health,
bridge process status, and localhost bind.
3. Add a bridge config validator that checks required env vars, token presence
on both services, domain selection, allowlist state, group-mode settings, and
writable thread-map path.
4. Add bridge tests for event dedupe, allowlist pairing, command dispatch,
group prefix handling, active-turn protection, and approval command parsing.
5. Add a manual end-to-end checklist for a fresh Lighthouse VM:
`/status`, prompt, `/interrupt`, approval allow/deny, `/threads`, `/resume`,
service restart, reboot persistence.
6. Tighten setup docs around the exact Feishu/Lark console fields:
bot capability, message permissions, `im.message.receive_v1`, long
connection mode, app release, bot DM pairing, and chat allowlist capture.
7. Add bridge logging that is useful in `journalctl`: startup config summary,
connection status, received message id, chosen thread id, turn id, approval
id, and compact runtime errors.
8. Add a release-note entry describing the Lighthouse + Feishu/Lark remote
control path and the supported first setup flow.
9. Add the CNB + Lighthouse + EdgeOne teaching shape without activating a live
CNB deployment pipeline before secrets, deploy key, and quota policy are
explicit.
## Acceptance
- A clean Tencent Lighthouse Ubuntu instance can be bootstrapped from the
documented branch.
- The Tencent-native onboarding doc explains when to use CNB, when to use
Lighthouse, and when EdgeOne is optional rather than required.
- CNB deploy examples are present but non-active until copied into `.cnb.yml`
and `.cnb/tag_deploy.yml`.
- `deepseek-runtime.service` starts and `/health` responds locally.
- `deepseek-feishu-bridge.service` connects through long connection mode.
- A Feishu/Lark phone DM can create a thread, run a prompt, interrupt a turn,
list threads, resume a thread, and answer a tool approval.
- `/status` reports runtime version, bind host, auth state, workspace, git repo,
branch, and dirty counts.
- After reboot, both services return to the same working state.
## References
- Tencent Lighthouse firewall docs:
`https://intl.cloud.tencent.com/document/product/1103/41393`
- Tencent Lighthouse SSH key docs:
`https://intl.cloud.tencent.com/ind/document/product/1103/41392`
- Lark/Feishu Node SDK:
`https://github.com/larksuite/node-sdk`
+35
View File
@@ -176,6 +176,19 @@ registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"
`rsproxy`, Tencent COS, and Aliyun OSS mirrors work the same way; pick whichever
is fastest from your network.
### Tencent Cloud remote-first setup
For an always-on workspace that can be controlled from a phone, use the
Tencent-native path instead of treating install as a single laptop step:
- CNB mirror/source: `https://cnb.cool/deepseek-tui.com/DeepSeek-TUI.git`
- Tencent Lighthouse HK: `/opt/whalebro` remote workspace
- Feishu/Lark: long-connection phone bridge
- EdgeOne: optional public HTTPS edge for docs/status/webhook surfaces
Start with [Tencent Cloud Remote-First Quickstart](TENCENT_CLOUD_REMOTE_FIRST.md),
then follow [Tencent Lighthouse Hong Kong Phone Setup](TENCENT_LIGHTHOUSE_HK.md).
---
## 5. Install via Nix
@@ -453,6 +466,28 @@ Set `DEEPSEEK_TUI_RELEASE_BASE_URL` to a mirrored release-asset directory
(rsproxy, TUNA, Tencent COS, Aliyun OSS), or skip npm entirely and use the
Cargo mirror setup in [Section 4](#4-install-via-cargo-any-tier-1-rust-target).
### `deepseek update` is blocked by GitHub from mainland China
`deepseek update` normally contacts GitHub Releases for metadata and binary
assets. On networks where GitHub is blocked or unreliable, use the CNB source
mirror instead and install both binaries from the release tag:
```bash
cargo install --git https://cnb.cool/deepseek-tui.com/DeepSeek-TUI --tag vX.Y.Z deepseek-tui-cli --locked --force
cargo install --git https://cnb.cool/deepseek-tui.com/DeepSeek-TUI --tag vX.Y.Z deepseek-tui --locked --force
```
If you operate a binary asset mirror, `deepseek update` can use it directly:
```bash
DEEPSEEK_TUI_VERSION=X.Y.Z \
DEEPSEEK_TUI_RELEASE_BASE_URL=https://your-mirror.example.com/DeepSeek-TUI/vX.Y.Z/ \
deepseek update
```
The mirror directory must contain `deepseek-artifacts-sha256.txt` and the
platform binaries from the GitHub release.
### Debian/Ubuntu: `feature edition2024 is required` from `cargo install`
Some Debian/Ubuntu distro packages ship an older Cargo that cannot parse Rust
+137
View File
@@ -0,0 +1,137 @@
# Tencent Cloud Remote-First Quickstart
This is the opinionated Tencent-native teaching path for DeepSeek TUI users
who want an always-on agent workspace, a phone control surface, and a stack
that works well from mainland China.
It complements the local install path. If you only want to use `deepseek` on a
laptop, start with the README quickstart. If you want "DS-TUI as a remote
workbench I can control from my phone", start here.
## Default Stack
```text
GitHub main/tags
-> CNB mirror: cnb.cool/deepseek-tui.com/DeepSeek-TUI
-> optional CNB build/deploy pipeline
-> Tencent Lighthouse HK
/opt/whalebro/deepseek-tui
/opt/whalebro/worktrees
deepseek-runtime.service on 127.0.0.1:7878
deepseek-feishu-bridge.service
-> Feishu/Lark phone DM
EdgeOne is optional:
public HTTPS domain -> EdgeOne -> Caddy/Nginx on Lighthouse
```
## What Each Piece Does
- **CNB** is the Tencent-side source and automation lane. The existing
`cnb.cool` mirror is useful for clones and tagged installs when GitHub is
slow. Optional CNB deploy templates live under
`deploy/tencent-lighthouse/cnb/`.
- **Lighthouse** is the private always-on host. It owns `/opt/whalebro`,
systemd, Rust/Node installs, and the `deepseek serve --http` runtime.
- **Feishu/Lark** is the first phone UI. The bridge uses long-connection mode,
so the first setup does not need a public webhook URL.
- **EdgeOne** is the public edge only when you intentionally expose a web
surface such as docs, a status page, or a future webhook endpoint. Do not put
the runtime API behind EdgeOne.
## First Lesson: Get a Remote Agent Running
1. Buy or reuse a Tencent Lighthouse instance in Hong Kong.
2. Clone from CNB by default when the branch or tag exists there:
```bash
export DEEPSEEK_REPO_URL=https://cnb.cool/deepseek-tui.com/DeepSeek-TUI.git
git ls-remote "$DEEPSEEK_REPO_URL" refs/heads/main
```
Tencent setup branches matching `work/v*-feishu-*` or
`work/v*-lighthouse*` are mirrored by the GitHub CNB sync workflow. Use
the GitHub URL only when the CNB workflow or credentials are unhealthy.
3. Bootstrap `/opt/whalebro` on the server:
```bash
export DEEPSEEK_BRANCH=main
git clone --branch "$DEEPSEEK_BRANCH" "$DEEPSEEK_REPO_URL" /tmp/deepseek-tui
cd /tmp/deepseek-tui
sudo DEEPSEEK_REPO_URL="$DEEPSEEK_REPO_URL" \
DEEPSEEK_REPO_BRANCH="$DEEPSEEK_BRANCH" \
bash scripts/tencent-lighthouse/bootstrap-ubuntu.sh
```
4. Install Rust for the `deepseek` user, build both binaries, and install the
systemd units using `docs/TENCENT_LIGHTHOUSE_HK.md`.
5. Configure a Feishu/Lark self-built app, fill
`/etc/deepseek/feishu-bridge.env`, run the validator, then run the VPS
doctor.
6. From your phone DM, validate `/status`, a harmless prompt, `/interrupt`,
`/threads`, `/resume`, approval allow/deny, service restart, and reboot
persistence.
## Second Lesson: Make CNB the Deploy Button
Once the manual Lighthouse path works, copy the non-active examples from
`deploy/tencent-lighthouse/cnb/` into the CNB repository:
- `cnb.yml.example` -> `.cnb.yml`
- `tag_deploy.yml.example` -> `.cnb/tag_deploy.yml`
The intended deploy button should:
1. Run bridge validation/tests and lightweight release-version checks.
2. SSH to Lighthouse with a deploy key stored as a CNB secret.
3. Update `/opt/whalebro/deepseek-tui`.
4. Rebuild/install both binaries.
5. Reinstall/restart systemd services.
6. Run `scripts/tencent-lighthouse/doctor.sh`.
Do not enable this on `main` until the deploy key, target host, billing/quota,
and rollback policy are explicit.
## Third Lesson: Add EdgeOne Only For Public HTTPS
The Feishu/Lark long-connection bridge works without EdgeOne. Add EdgeOne when
you want a public domain in front of a deliberate HTTP service:
- a public tutorial/docs site
- a small operator status page
- a future webhook-mode bridge
- a demo app running on the same Lighthouse origin
Keep these rules:
- `deepseek serve --http` stays bound to `127.0.0.1`.
- `/v1/*` runtime endpoints are never public.
- `DEEPSEEK_RUNTIME_TOKEN` never leaves the server env files.
- Feishu/Lark group control stays off until a specific group allowlist is set.
- Auto-approval stays off for the phone bridge unless a maintainer explicitly
accepts the risk.
## Teaching Order
Use this sequence when explaining DeepSeek TUI to a new remote-first user:
1. **Local mental model:** `deepseek` is the dispatcher, `deepseek-tui` is the
companion runtime, and both binaries matter.
2. **Agent safety:** Plan/Agent/YOLO are separate from approval mode and
sandboxing.
3. **Remote runtime:** `deepseek serve --http` is a localhost runtime API, not
a public web app.
4. **Phone bridge:** Feishu/Lark messages become runtime requests through an
allowlisted bridge.
5. **CNB automation:** once manual setup is proven, CNB turns the setup into a
repeatable deploy button.
6. **EdgeOne edge:** add the public edge after you know exactly what public
surface you are exposing.
## References
- CNB mirror details: `docs/CNB_MIRROR.md`
- Lighthouse implementation runbook: `docs/TENCENT_LIGHTHOUSE_HK.md`
- Feishu/Lark bridge: `integrations/feishu-bridge/README.md`
- CNB templates: `deploy/tencent-lighthouse/cnb/`
+102
View File
@@ -0,0 +1,102 @@
# Tencent Lighthouse + Lark Setup Handoff Prompt
Use this prompt with a Computer Use capable agent when you are ready to create
the Tencent Lighthouse instance and Lark/Feishu app.
```text
You are taking over a live setup task on my Mac. Use Computer Use/browser UI for the Tencent Cloud and Feishu/Lark consoles. Require explicit confirmation before purchases, external submissions, sending bot messages to other people, deleting files, or entering secrets.
Goal:
Set up a Tencent Cloud Lighthouse Hong Kong VPS and a Feishu/Lark self-built bot so I can control a remote /opt/whalebro workspace from my phone while traveling in China.
Repo/workspace:
- Canonical repo: /Volumes/VIXinSSD/whalebro/deepseek-tui
- Product repo to include on the VPS when requested: /Volumes/VIXinSSD/whalebro/whalescale
- Read /Volumes/VIXinSSD/whalebro/AGENTS.md and /Volumes/VIXinSSD/whalebro/deepseek-tui/AGENTS.md before editing.
- The repo now has a first-pass deployment/runbook under:
- docs/TENCENT_LIGHTHOUSE_HK.md
- docs/FEISHU_LIGHTHOUSE_V0_8_37_PLAN.md
- integrations/feishu-bridge/
- deploy/tencent-lighthouse/
- scripts/tencent-lighthouse/
- Current working branch with this setup: work/v0.8.37-feishu-lighthouse. Verify it is pushed before relying on a VPS git clone.
- Current CNB mirror for this branch: https://cnb.cool/deepseek-tui.com/DeepSeek-TUI.git refs/heads/work/v0.8.37-feishu-lighthouse.
- Remote-first overview: docs/TENCENT_CLOUD_REMOTE_FIRST.md.
- CNB deploy templates are non-active examples under deploy/tencent-lighthouse/cnb/.
Important architecture:
- Use plain Ubuntu 24.04 LTS on Tencent Lighthouse Hong Kong.
- Buy the HK Linux 2 vCPU / 4 GB / 70 GB / 30M / 2 TB per month plan first, preferably 1 month.
- The runtime must stay bound to 127.0.0.1:7878 on the VPS.
- The phone-facing channel is the Feishu/Lark bot long connection service.
- CNB is the preferred source/deploy lane once the branch exists there.
- EdgeOne is optional and should only front a deliberate public HTTPS service; do not expose /v1 runtime endpoints through it.
- Direct message control is the MVP. Keep FEISHU_ALLOW_GROUPS=false initially.
- The VPS workspace root is /opt/whalebro.
- Required checkout: /opt/whalebro/deepseek-tui.
- Optional checkout if I want the full active workspace: /opt/whalebro/whalescale.
- Use /opt/whalebro/worktrees for worktrees intentionally created on the VPS.
- If these deployment files are not pushed to Git yet, either help me push the branch first or copy the current local checkout to the VPS. A fresh VPS clone cannot see uncommitted local files.
Secrets to collect from me interactively:
- Tencent Cloud login/session if not already logged in.
- SSH public key to add to Lighthouse.
- DeepSeek API key for /etc/deepseek/runtime.env.
- Runtime bearer token: generate with openssl rand -hex 32.
- Feishu/Lark App ID and App Secret from the self-built app.
Tencent Cloud steps:
1. Open Tencent Cloud Lighthouse purchase page.
2. Select Hong Kong, China region.
3. Select plain Ubuntu 24.04 LTS or latest Ubuntu LTS.
4. Select the HK 2c/4G/70G monthly plan first.
5. Use SSH key login, not password login.
6. Confirm firewall/security group keeps SSH open.
7. Ask me before clicking final purchase/checkout.
8. After purchase, record the public IP and SSH command.
Feishu/Lark steps:
1. Open Feishu China or Lark international developer console, whichever matches my account.
2. Create an enterprise self-built app.
3. Enable bot capability.
4. Add message receive/send permissions required for text DMs.
5. Add event subscription for im.message.receive_v1.
6. Use long connection/WebSocket mode.
7. Publish/release the app as required by the console.
8. Add the bot to my own DM chat first.
VPS setup steps:
1. SSH into the instance.
2. Clone the repo from CNB when available and run docs/TENCENT_LIGHTHOUSE_HK.md exactly, adapting only branch/repo URL if needed.
3. Run:
sudo DEEPSEEK_REPO_URL=https://cnb.cool/deepseek-tui.com/DeepSeek-TUI.git DEEPSEEK_REPO_BRANCH=work/v0.8.37-feishu-lighthouse bash scripts/tencent-lighthouse/bootstrap-ubuntu.sh
If I confirm I want whalescale on the VPS immediately, use:
sudo DEEPSEEK_REPO_URL=https://cnb.cool/deepseek-tui.com/DeepSeek-TUI.git DEEPSEEK_REPO_BRANCH=work/v0.8.37-feishu-lighthouse WHALEBRO_EXTRA_REPOS='whalescale=https://github.com/Hmbown/whalescale.git' bash scripts/tencent-lighthouse/bootstrap-ubuntu.sh
Use SSH remotes instead if the repo is private or I need push access from the VPS.
4. Install Rust 1.88+ for the deepseek user via rustup minimal profile.
5. Build/install both binaries:
cargo install --path crates/cli --locked --force
cargo install --path crates/tui --locked --force
6. Run:
sudo bash scripts/tencent-lighthouse/install-services.sh
7. Edit /etc/deepseek/runtime.env and /etc/deepseek/feishu-bridge.env.
8. Validate bridge/runtime config:
sudo -u deepseek node /opt/deepseek/bridge/scripts/validate-config.mjs --env /etc/deepseek/feishu-bridge.env --runtime-env /etc/deepseek/runtime.env --workspace-root /opt/whalebro --check-filesystem
9. Start deepseek-runtime and verify:
curl -s http://127.0.0.1:7878/health
10. Start deepseek-feishu-bridge and tail logs.
11. Run:
sudo bash /opt/whalebro/deepseek-tui/scripts/tencent-lighthouse/doctor.sh
12. Pair by temporarily setting DEEPSEEK_ALLOW_UNLISTED=true if needed, DM the bot, copy the returned chat_id, set DEEPSEEK_CHAT_ALLOWLIST to that chat_id, then turn DEEPSEEK_ALLOW_UNLISTED=false.
Validation:
- From phone DM, send /status.
- Confirm the bot reports runtime, version, bind host, and workspace status.
- Send a harmless prompt: "summarize git status".
- Confirm the runtime bind host is 127.0.0.1.
- Validate /interrupt, /threads, /resume, /allow, and /deny from the phone DM.
- Run systemctl status for both services.
- Restart both services and confirm /status still works.
- Reboot the instance and confirm both services return active.
- Capture final IP, SSH command, service status, and any remaining blockers.
```
+307
View File
@@ -0,0 +1,307 @@
# Tencent Lighthouse Hong Kong Phone Setup
This runbook sets up a Tencent Cloud Lighthouse instance in Hong Kong as an
always-on DeepSeek TUI host controlled from Feishu/Lark on a phone.
If you are teaching this as the Tencent-native default path, start with
[docs/TENCENT_CLOUD_REMOTE_FIRST.md](TENCENT_CLOUD_REMOTE_FIRST.md). This file
is the implementation runbook for the Lighthouse host itself.
## Target Architecture
```text
CNB mirror or GitHub branch
-> /opt/whalebro/deepseek-tui
Feishu/Lark mobile app
-> Feishu/Lark long-connection bot
-> deepseek-feishu-bridge systemd service
-> http://127.0.0.1:7878 deepseek serve --http
-> /opt/whalebro
-> deepseek-tui/
-> whalescale/ when product work is needed
Optional public edge:
EdgeOne -> Caddy/Nginx public site on Lighthouse
```
The runtime API must stay on `127.0.0.1`. The bridge is the only phone-facing
control surface. EdgeOne is optional and should only front a deliberate public
HTTP service, not the runtime API.
## Remote Whalebro Workspace
Use `/opt/whalebro` as the VPS workspace root. The first-class checkout is
`/opt/whalebro/deepseek-tui`; add `/opt/whalebro/whalescale` if you want the
desktop product repo available from the phone too.
Create these paths first:
- `/opt/whalebro/deepseek-tui`
- `/opt/whalebro/whalescale`
- `/opt/whalebro/worktrees`
Linux is enough for Rust, Node, service work, and most `whalescale-desktop`
web/Tauri development. Mac-only release work such as iOS simulator runs,
`.app`/DMG checks, notarization, and Apple signing still belongs on the Mac.
## Lighthouse Instance
Recommended package for travel:
- Region: Hong Kong (China)
- Image: plain Ubuntu 24.04 LTS or latest Ubuntu LTS
- Size: buy the HK 2 vCPU / 4 GB / 70 GB plan for the first month
- Login: SSH key, not password
- Firewall: SSH open; runtime API on localhost only
Tencent's Lighthouse docs say Linux instances can use SSH keys, and the
Lighthouse firewall opens SSH/HTTP/HTTPS by default.
Use 4 GB RAM for compiling Rust and running the bridge comfortably. A 4 vCPU /
8 GB plan is better for multiple parallel agent workers.
## Feishu / Lark App
Create an enterprise self-built app in:
- Feishu China: `https://open.feishu.cn/app`
- Lark international: `https://open.larksuite.com/app`
Configure:
1. Enable bot capability.
2. Copy App ID and App Secret.
3. Add permissions for message send/receive. The minimum practical set is:
- `im:message`
- `im:message:send_as_bot`
- direct message read permission for your tenant
- group @message read permission only if you intentionally enable group
control later
4. Add event subscription `im.message.receive_v1`.
5. Use long connection / WebSocket mode.
6. Publish the app and add the bot to your Feishu/Lark chat.
## Server Bootstrap
SSH into the Lighthouse instance and run:
```bash
sudo apt-get update
sudo apt-get install -y git
export DEEPSEEK_BRANCH=work/v0.8.37-feishu-lighthouse
export DEEPSEEK_REPO_URL=https://cnb.cool/deepseek-tui.com/DeepSeek-TUI.git
git clone --branch "$DEEPSEEK_BRANCH" "$DEEPSEEK_REPO_URL" /tmp/deepseek-tui
cd /tmp/deepseek-tui
sudo DEEPSEEK_REPO_URL="$DEEPSEEK_REPO_URL" \
DEEPSEEK_REPO_BRANCH="$DEEPSEEK_BRANCH" \
bash scripts/tencent-lighthouse/bootstrap-ubuntu.sh
```
If you also want `whalescale` cloned during bootstrap, pass it explicitly:
```bash
sudo DEEPSEEK_REPO_URL="$DEEPSEEK_REPO_URL" \
DEEPSEEK_REPO_BRANCH="$DEEPSEEK_BRANCH" \
WHALEBRO_EXTRA_REPOS='whalescale=https://github.com/Hmbown/whalescale.git' \
bash scripts/tencent-lighthouse/bootstrap-ubuntu.sh
```
Use SSH repo URLs instead if either repo is private or you want push access
from the VPS. If the CNB mirror is unavailable, fall back to:
```bash
export DEEPSEEK_REPO_URL=https://github.com/Hmbown/DeepSeek-TUI.git
```
For stable release docs, confirm the CNB mirror has the branch or tag before
using it:
```bash
export DEEPSEEK_REPO_URL=https://cnb.cool/deepseek-tui.com/DeepSeek-TUI.git
git ls-remote "$DEEPSEEK_REPO_URL" \
refs/heads/work/v0.8.37-feishu-lighthouse \
refs/tags/v0.8.37
```
The CNB mirror receives `main`, release tags, and Tencent setup branches that
match `work/v*-feishu-*` or `work/v*-lighthouse*`. CNB is the default source
for this Lighthouse path; GitHub is the fallback only when the CNB workflow or
credentials are unhealthy.
If this deployment setup has not been pushed to Git yet, either push the branch
first or copy this checkout to the VPS before running these commands. A fresh
VPS clone cannot see uncommitted local files.
Install Rust 1.88+ for the `deepseek` user, then build both shipped binaries:
```bash
sudo -iu deepseek
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh
sed -n '1,120p' /tmp/rustup-init.sh
sh /tmp/rustup-init.sh -y --profile minimal
. "$HOME/.cargo/env"
rustup default stable
cd /opt/whalebro/deepseek-tui
cargo install --path crates/cli --locked --force
cargo install --path crates/tui --locked --force
exit
```
Copy and install the bridge/service files:
```bash
cd /opt/whalebro/deepseek-tui
sudo bash scripts/tencent-lighthouse/install-services.sh
```
After editing both env files, validate the bridge/runtime pairing:
```bash
sudo -u deepseek node /opt/deepseek/bridge/scripts/validate-config.mjs \
--env /etc/deepseek/feishu-bridge.env \
--runtime-env /etc/deepseek/runtime.env \
--workspace-root /opt/whalebro \
--check-filesystem
```
## Secrets
Generate one runtime token and put the same value in both env files:
```bash
openssl rand -hex 32
sudoedit /etc/deepseek/runtime.env
sudoedit /etc/deepseek/feishu-bridge.env
```
Required values:
- `/etc/deepseek/runtime.env`
- `DEEPSEEK_API_KEY`
- `DEEPSEEK_RUNTIME_TOKEN`
- `/etc/deepseek/feishu-bridge.env`
- `FEISHU_APP_ID`
- `FEISHU_APP_SECRET`
- `FEISHU_DOMAIN=feishu` for Feishu, `lark` for Lark
- `DEEPSEEK_RUNTIME_TOKEN`
- `FEISHU_ALLOW_GROUPS=false` for the first deployment
For first pairing, either:
1. Temporarily set `DEEPSEEK_ALLOW_UNLISTED=true`, message the bot, copy the
returned `chat_id`, then set `DEEPSEEK_CHAT_ALLOWLIST=<chat_id>` and turn
unlisted access back off.
2. Or obtain the chat ID from Feishu/Lark event logs and set the allowlist
before first start.
## Start Services
```bash
sudo systemctl start deepseek-runtime
sudo systemctl status deepseek-runtime --no-pager
curl -s http://127.0.0.1:7878/health
sudo systemctl start deepseek-feishu-bridge
sudo journalctl -u deepseek-feishu-bridge -f
```
Run the Lighthouse doctor after both services are configured:
```bash
cd /opt/whalebro/deepseek-tui
sudo bash scripts/tencent-lighthouse/doctor.sh
```
Enable on boot is done by `install-services.sh`; if needed:
```bash
sudo systemctl enable deepseek-runtime deepseek-feishu-bridge
```
## Phone Commands
DMs can be plain text and are the intended first control path:
```text
check git status and summarize what needs attention
```
Group chats are disabled by default. If you later set
`FEISHU_ALLOW_GROUPS=true`, group prompts must start with `/ds`.
Useful commands:
- `/status`
- `/threads`
- `/new`
- `/resume <thread_id>`
- `/interrupt`
- `/compact`
- `/allow <approval_id>`
- `/deny <approval_id>`
- `/allow <approval_id> remember`
Use `remember` only when you intentionally want the runtime thread to flip
toward auto-approval for future tools.
## CNB Deploy Button
After the manual Lighthouse setup passes, CNB can become the repeatable deploy
button:
1. Copy `deploy/tencent-lighthouse/cnb/cnb.yml.example` to `.cnb.yml` in the
CNB repo.
2. Copy `deploy/tencent-lighthouse/cnb/tag_deploy.yml.example` to
`.cnb/tag_deploy.yml`.
3. Configure the CNB deploy secrets documented in
`deploy/tencent-lighthouse/cnb/README.md`.
4. Trigger the `lighthouse-hk` deployment environment.
Keep this manual until the server is boring. Automatic deploys on every push
are convenient later, but they can consume CNB quota and restart the bridge
while a phone turn is active.
## EdgeOne
EdgeOne is not required for the first Feishu/Lark long-connection setup. Add it
only when you need a public HTTPS domain in front of a deliberate public
service on the Lighthouse host.
Good EdgeOne uses:
- public docs or tutorial site
- tiny operator status page
- future webhook-mode bridge endpoint
- demo web app hosted on the same Lighthouse instance
Do not use EdgeOne to expose:
- `http://127.0.0.1:7878`
- `/v1/*` runtime endpoints
- any endpoint that accepts `DEEPSEEK_RUNTIME_TOKEN`
## End-to-End Validation
From a phone DM to the bot:
1. Send `/status` and confirm runtime version, localhost bind, auth state,
workspace, git repo, branch, and dirty counts.
2. Send a harmless prompt such as `summarize git status`.
3. Send `/interrupt` while a turn is active and confirm the turn stops.
4. Send `/threads`, then `/resume <thread_id>` for one listed thread.
5. Trigger a tool approval and verify both `/allow <approval_id>` and
`/deny <approval_id>` paths.
6. Restart both services and re-run `/status`.
7. Reboot the instance, then confirm `systemctl status deepseek-runtime` and
`systemctl status deepseek-feishu-bridge` return to active.
## Operational Notes
- Bind `deepseek serve --http` to `127.0.0.1`.
- Keep the Lighthouse firewall focused on SSH for this setup.
- Use SSH key auth.
- Use `tmux` for emergency terminal work from Blink/Termius.
- Keep `/opt/whalebro/deepseek-tui` on a personal branch while working from the
phone.
- Keep `/opt/whalebro/whalescale` on its own branch when doing product work.
+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/);
});
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo bash scripts/tencent-lighthouse/bootstrap-ubuntu.sh" >&2
exit 1
fi
DEEPSEEK_USER="${DEEPSEEK_USER:-deepseek}"
DEEPSEEK_ROOT="${DEEPSEEK_ROOT:-/opt/deepseek}"
WHALEBRO_ROOT="${WHALEBRO_ROOT:-/opt/whalebro}"
REPO_URL="${DEEPSEEK_REPO_URL:-https://github.com/Hmbown/DeepSeek-TUI.git}"
WHALEBRO_EXTRA_REPOS="${WHALEBRO_EXTRA_REPOS:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
SOURCE_BRANCH="$(git -C "${SOURCE_ROOT}" branch --show-current 2>/dev/null || true)"
REPO_BRANCH="${DEEPSEEK_REPO_BRANCH:-${SOURCE_BRANCH:-main}}"
apt-get update
apt-get install -y \
ca-certificates \
curl \
git \
iproute2 \
openssh-client \
build-essential \
pkg-config \
libssl-dev \
nodejs \
npm \
rsync \
tmux \
fail2ban \
ufw
node_major="$(node -p "Number(process.versions.node.split('.')[0])")"
if (( node_major < 18 )); then
echo "Node.js 18+ is required for the Feishu bridge; install a newer Node.js before running install-services.sh." >&2
fi
if ! id -u "${DEEPSEEK_USER}" >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash "${DEEPSEEK_USER}"
fi
install -d -o "${DEEPSEEK_USER}" -g "${DEEPSEEK_USER}" "${DEEPSEEK_ROOT}"
install -d -o "${DEEPSEEK_USER}" -g "${DEEPSEEK_USER}" "${DEEPSEEK_ROOT}/bridge"
install -d -o "${DEEPSEEK_USER}" -g "${DEEPSEEK_USER}" "${WHALEBRO_ROOT}"
install -d -o "${DEEPSEEK_USER}" -g "${DEEPSEEK_USER}" "${WHALEBRO_ROOT}/worktrees"
install -d -m 0750 -o root -g "${DEEPSEEK_USER}" /etc/deepseek
install -d -m 0700 -o "${DEEPSEEK_USER}" -g "${DEEPSEEK_USER}" /var/lib/deepseek-feishu-bridge
if [[ ! -d "${WHALEBRO_ROOT}/deepseek-tui/.git" ]]; then
sudo -u "${DEEPSEEK_USER}" git clone --branch "${REPO_BRANCH}" "${REPO_URL}" "${WHALEBRO_ROOT}/deepseek-tui"
fi
for repo_spec in ${WHALEBRO_EXTRA_REPOS}; do
repo_name="${repo_spec%%=*}"
repo_url="${repo_spec#*=}"
if [[ -z "${repo_name}" || -z "${repo_url}" || "${repo_name}" == "${repo_url}" ]]; then
echo "Skipping malformed WHALEBRO_EXTRA_REPOS entry: ${repo_spec}" >&2
continue
fi
if [[ ! -d "${WHALEBRO_ROOT}/${repo_name}/.git" ]]; then
sudo -u "${DEEPSEEK_USER}" git clone "${repo_url}" "${WHALEBRO_ROOT}/${repo_name}" || {
echo "Warning: failed to clone optional repo ${repo_name} from ${repo_url}" >&2
}
fi
done
if [[ ! -f "${WHALEBRO_ROOT}/AGENTS.md" && -f "${SOURCE_ROOT}/deploy/tencent-lighthouse/examples/whalebro.AGENTS.md" ]]; then
install -m 0644 -o "${DEEPSEEK_USER}" -g "${DEEPSEEK_USER}" \
"${SOURCE_ROOT}/deploy/tencent-lighthouse/examples/whalebro.AGENTS.md" \
"${WHALEBRO_ROOT}/AGENTS.md"
fi
if [[ ! -f /etc/deepseek/runtime.env ]]; then
cat >/etc/deepseek/runtime.env <<'EOF'
DEEPSEEK_RUNTIME_TOKEN=replace-with-long-random-token
DEEPSEEK_RUNTIME_PORT=7878
DEEPSEEK_RUNTIME_WORKERS=2
DEEPSEEK_API_KEY=replace-with-deepseek-platform-key
RUST_LOG=info
EOF
chown root:"${DEEPSEEK_USER}" /etc/deepseek/runtime.env
chmod 0640 /etc/deepseek/runtime.env
fi
if [[ ! -f /etc/deepseek/feishu-bridge.env ]]; then
cat >/etc/deepseek/feishu-bridge.env <<'EOF'
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-same-token-as-runtime-env
DEEPSEEK_WORKSPACE=/opt/whalebro
DEEPSEEK_MODEL=auto
DEEPSEEK_MODE=agent
DEEPSEEK_ALLOW_SHELL=true
DEEPSEEK_TRUST_MODE=false
DEEPSEEK_AUTO_APPROVE=false
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
EOF
chown root:"${DEEPSEEK_USER}" /etc/deepseek/feishu-bridge.env
chmod 0640 /etc/deepseek/feishu-bridge.env
fi
ufw allow OpenSSH
ufw --force enable
cat <<EOF
Base server setup complete.
Next:
1. Install Rust 1.88+ for ${DEEPSEEK_USER}; rustup is the usual path.
2. Build/install both binaries:
sudo -iu ${DEEPSEEK_USER}
cd ${WHALEBRO_ROOT}/deepseek-tui
cargo install --path crates/cli --locked --force
cargo install --path crates/tui --locked --force
3. Copy integrations/feishu-bridge to ${DEEPSEEK_ROOT}/bridge and run npm install.
4. Edit /etc/deepseek/runtime.env and /etc/deepseek/feishu-bridge.env.
5. Install systemd units with scripts/tencent-lighthouse/install-services.sh.
6. After the env files are edited and services are started, run:
sudo bash scripts/tencent-lighthouse/doctor.sh
EOF
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env bash
set -euo pipefail
DEEPSEEK_USER="${DEEPSEEK_USER:-deepseek}"
DEEPSEEK_ROOT="${DEEPSEEK_ROOT:-/opt/deepseek}"
WHALEBRO_ROOT="${WHALEBRO_ROOT:-/opt/whalebro}"
RUNTIME_ENV="${RUNTIME_ENV:-/etc/deepseek/runtime.env}"
BRIDGE_ENV="${BRIDGE_ENV:-/etc/deepseek/feishu-bridge.env}"
BRIDGE_DIR="${BRIDGE_DIR:-${DEEPSEEK_ROOT}/bridge}"
REPO_ROOT="${REPO_ROOT:-${WHALEBRO_ROOT}/deepseek-tui}"
failures=0
warnings=0
section() {
printf '\n== %s ==\n' "$1"
}
pass() {
printf '[ok] %s\n' "$1"
}
warn() {
warnings=$((warnings + 1))
printf '[warn] %s\n' "$1"
}
fail() {
failures=$((failures + 1))
printf '[fail] %s\n' "$1"
}
have_command() {
command -v "$1" >/dev/null 2>&1
}
env_value() {
local file="$1"
local key="$2"
[[ -f "${file}" ]] || return 0
grep -E "^[[:space:]]*(export[[:space:]]+)?${key}=" "${file}" \
| tail -n 1 \
| sed -E "s/^[[:space:]]*(export[[:space:]]+)?${key}=//; s/^[[:space:]]+//; s/[[:space:]]+$//; s/^['\"]//; s/['\"]$//" \
|| true
}
is_placeholder() {
local value
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')"
[[ -z "${value}" || "${value}" == *replace-with* || "${value}" == *xxxxxxxx* || "${value}" == "changeme" ]]
}
file_mode() {
if stat -c '%a' "$1" >/dev/null 2>&1; then
stat -c '%a' "$1"
else
stat -f '%Lp' "$1"
fi
}
check_commands() {
section "Runtime tools"
for cmd in git curl node npm systemctl ss; do
if have_command "${cmd}"; then
pass "${cmd} is installed"
else
warn "${cmd} is not on PATH"
fi
done
}
check_node() {
section "Node"
if ! have_command node; then
fail "node is required for the Feishu bridge"
return
fi
local major
major="$(node -p "Number(process.versions.node.split('.')[0])" 2>/dev/null || echo 0)"
if [[ "${major}" =~ ^[0-9]+$ ]] && (( major >= 18 )); then
pass "Node.js major version is ${major}"
else
fail "Node.js 18+ is required; found ${major}"
fi
}
check_workspace() {
section "Workspace"
[[ -d "${WHALEBRO_ROOT}" ]] && pass "${WHALEBRO_ROOT} exists" || fail "${WHALEBRO_ROOT} is missing"
[[ -d "${REPO_ROOT}/.git" ]] && pass "${REPO_ROOT} is a git checkout" || fail "${REPO_ROOT} is not a git checkout"
[[ -d "${WHALEBRO_ROOT}/worktrees" ]] && pass "${WHALEBRO_ROOT}/worktrees exists" || warn "${WHALEBRO_ROOT}/worktrees is missing"
if [[ -f "${WHALEBRO_ROOT}/AGENTS.md" ]]; then
pass "${WHALEBRO_ROOT}/AGENTS.md exists"
else
warn "${WHALEBRO_ROOT}/AGENTS.md is missing"
fi
}
check_binaries() {
section "DeepSeek binaries"
local cargo_bin="/home/${DEEPSEEK_USER}/.cargo/bin"
local deepseek="${cargo_bin}/deepseek"
local tui="${cargo_bin}/deepseek-tui"
if [[ -x "${deepseek}" ]]; then
pass "${deepseek} is executable"
"${deepseek}" --version 2>/dev/null | sed 's/^/[info] deepseek version: /' || warn "deepseek --version failed"
else
fail "${deepseek} is missing or not executable"
fi
if [[ -x "${tui}" ]]; then
pass "${tui} is executable"
"${tui}" --version 2>/dev/null | sed 's/^/[info] deepseek-tui version: /' || warn "deepseek-tui --version failed"
else
fail "${tui} is missing or not executable"
fi
}
check_env_file() {
local file="$1"
local label="$2"
if [[ ! -f "${file}" ]]; then
fail "${label} env file is missing: ${file}"
return
fi
pass "${label} env file exists"
local mode
mode="$(file_mode "${file}")"
local world="${mode: -1}"
if [[ "${world}" =~ ^[0-9]+$ ]] && (( world > 0 )); then
fail "${label} env file is world-readable (${mode})"
else
pass "${label} env file is not world-readable (${mode})"
fi
}
check_env() {
section "Environment"
check_env_file "${RUNTIME_ENV}" "runtime"
check_env_file "${BRIDGE_ENV}" "bridge"
local runtime_token bridge_token api_key workspace domain allow_groups allow_unlisted
runtime_token="$(env_value "${RUNTIME_ENV}" DEEPSEEK_RUNTIME_TOKEN)"
bridge_token="$(env_value "${BRIDGE_ENV}" DEEPSEEK_RUNTIME_TOKEN)"
api_key="$(env_value "${RUNTIME_ENV}" DEEPSEEK_API_KEY)"
workspace="$(env_value "${BRIDGE_ENV}" DEEPSEEK_WORKSPACE)"
domain="$(env_value "${BRIDGE_ENV}" FEISHU_DOMAIN)"
allow_groups="$(env_value "${BRIDGE_ENV}" FEISHU_ALLOW_GROUPS)"
allow_unlisted="$(env_value "${BRIDGE_ENV}" DEEPSEEK_ALLOW_UNLISTED)"
if is_placeholder "${runtime_token}"; then
fail "runtime DEEPSEEK_RUNTIME_TOKEN is missing or still a placeholder"
else
pass "runtime token is set"
fi
if is_placeholder "${bridge_token}"; then
fail "bridge DEEPSEEK_RUNTIME_TOKEN is missing or still a placeholder"
else
pass "bridge token is set"
fi
if [[ -n "${runtime_token}" && -n "${bridge_token}" && "${runtime_token}" != "${bridge_token}" ]]; then
fail "runtime and bridge tokens do not match"
elif [[ -n "${runtime_token}" && -n "${bridge_token}" ]]; then
pass "runtime and bridge tokens match"
fi
if is_placeholder "${api_key}"; then
warn "DEEPSEEK_API_KEY is missing or still a placeholder"
else
pass "DEEPSEEK_API_KEY is set"
fi
[[ "${workspace}" == "${WHALEBRO_ROOT}" || "${workspace}" == "${WHALEBRO_ROOT}/"* ]] \
&& pass "bridge workspace is under ${WHALEBRO_ROOT}" \
|| warn "bridge workspace is outside ${WHALEBRO_ROOT}: ${workspace:-unset}"
[[ "${domain:-feishu}" == "feishu" || "${domain:-feishu}" == "lark" || "${domain:-feishu}" == https://open.* ]] \
&& pass "FEISHU_DOMAIN is ${domain:-feishu}" \
|| fail "FEISHU_DOMAIN must be feishu, lark, or an https://open.* URL"
[[ "${allow_groups:-false}" == "true" && "${allow_unlisted:-false}" == "true" ]] \
&& fail "group control cannot run with DEEPSEEK_ALLOW_UNLISTED=true" \
|| pass "group/unlisted mode is not openly combined"
}
check_validator() {
section "Bridge config validator"
local validator="${BRIDGE_DIR}/scripts/validate-config.mjs"
if [[ ! -f "${validator}" ]]; then
validator="${REPO_ROOT}/integrations/feishu-bridge/scripts/validate-config.mjs"
fi
if [[ ! -f "${validator}" ]]; then
warn "bridge config validator is not installed"
return
fi
local runner=(node)
if [[ "${EUID}" -eq 0 ]] && id -u "${DEEPSEEK_USER}" >/dev/null 2>&1 && have_command sudo; then
runner=(sudo -u "${DEEPSEEK_USER}" node)
fi
if "${runner[@]}" "${validator}" --env "${BRIDGE_ENV}" --runtime-env "${RUNTIME_ENV}" --workspace-root "${WHALEBRO_ROOT}" --check-filesystem; then
pass "bridge config validator passed"
else
fail "bridge config validator reported blocking issues"
fi
}
check_systemd() {
section "systemd"
if ! have_command systemctl || [[ ! -d /run/systemd/system ]]; then
warn "systemd is not available in this environment"
return
fi
for unit in deepseek-runtime deepseek-feishu-bridge; do
[[ -f "/etc/systemd/system/${unit}.service" ]] \
&& pass "${unit}.service is installed" \
|| fail "${unit}.service is missing"
systemctl is-enabled --quiet "${unit}" \
&& pass "${unit} is enabled" \
|| warn "${unit} is not enabled"
systemctl is-active --quiet "${unit}" \
&& pass "${unit} is active" \
|| fail "${unit} is not active"
done
}
check_bridge_install() {
section "Bridge install"
[[ -f "${BRIDGE_DIR}/package.json" ]] && pass "${BRIDGE_DIR}/package.json exists" || fail "bridge package.json is missing"
[[ -f "${BRIDGE_DIR}/src/index.mjs" ]] && pass "${BRIDGE_DIR}/src/index.mjs exists" || fail "bridge entrypoint is missing"
if [[ -d "${BRIDGE_DIR}/node_modules/@larksuiteoapi/node-sdk" ]]; then
pass "Lark SDK dependency is installed"
else
warn "Lark SDK dependency is not installed under ${BRIDGE_DIR}/node_modules"
fi
}
check_localhost_health() {
section "Localhost health"
local port token
port="$(env_value "${RUNTIME_ENV}" DEEPSEEK_RUNTIME_PORT)"
port="${port:-7878}"
token="$(env_value "${BRIDGE_ENV}" DEEPSEEK_RUNTIME_TOKEN)"
if have_command ss; then
local listeners
listeners="$(ss -ltn 2>/dev/null | awk -v port=":${port}" '$4 ~ port {print $4}' || true)"
if grep -qE "^127\\.0\\.0\\.1:${port}$|^\\[::1\\]:${port}$" <<<"${listeners}"; then
pass "runtime port ${port} is bound to localhost"
elif [[ -n "${listeners}" ]]; then
fail "runtime port ${port} is listening on a non-local address: ${listeners//$'\n'/, }"
else
fail "runtime port ${port} is not listening"
fi
else
warn "ss is unavailable; skipping bind-address check"
fi
if ! have_command curl; then
warn "curl is unavailable; skipping HTTP checks"
return
fi
if curl -fsS --max-time 3 "http://127.0.0.1:${port}/health" >/dev/null; then
pass "/health responds on localhost"
else
fail "/health did not respond on localhost:${port}"
fi
if is_placeholder "${token}"; then
warn "runtime token is not usable; skipping /v1/runtime/info auth check"
return
fi
local tmp
tmp="$(mktemp)"
if curl -fsS --max-time 3 -H "Authorization: Bearer ${token}" \
"http://127.0.0.1:${port}/v1/runtime/info" >"${tmp}"; then
if node -e '
const fs = require("fs");
const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
if (data.bind_host !== "127.0.0.1") process.exit(2);
if (data.auth_required !== true) process.exit(3);
' "${tmp}"; then
pass "/v1/runtime/info reports localhost bind and auth_required=true"
else
fail "/v1/runtime/info did not report localhost bind with auth enabled"
fi
else
fail "/v1/runtime/info did not respond with bearer auth"
fi
rm -f "${tmp}"
}
main() {
printf 'Tencent Lighthouse DeepSeek doctor\n'
check_commands
check_node
check_workspace
check_binaries
check_env
check_bridge_install
check_validator
check_systemd
check_localhost_health
section "Summary"
printf '%s failure(s), %s warning(s)\n' "${failures}" "${warnings}"
(( failures == 0 ))
}
main "$@"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo bash scripts/tencent-lighthouse/install-services.sh" >&2
exit 1
fi
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DEEPSEEK_USER="${DEEPSEEK_USER:-deepseek}"
DEEPSEEK_ROOT="${DEEPSEEK_ROOT:-/opt/deepseek}"
install -d -o "${DEEPSEEK_USER}" -g "${DEEPSEEK_USER}" "${DEEPSEEK_ROOT}/bridge"
rsync -a --delete \
--exclude node_modules \
"${REPO_ROOT}/integrations/feishu-bridge/" \
"${DEEPSEEK_ROOT}/bridge/"
chown -R "${DEEPSEEK_USER}:${DEEPSEEK_USER}" "${DEEPSEEK_ROOT}/bridge"
if [[ -f "${DEEPSEEK_ROOT}/bridge/package-lock.json" ]]; then
sudo -u "${DEEPSEEK_USER}" npm --prefix "${DEEPSEEK_ROOT}/bridge" ci --omit=dev
else
sudo -u "${DEEPSEEK_USER}" npm --prefix "${DEEPSEEK_ROOT}/bridge" install --omit=dev
fi
install -m 0644 "${REPO_ROOT}/deploy/tencent-lighthouse/systemd/deepseek-runtime.service" /etc/systemd/system/deepseek-runtime.service
install -m 0644 "${REPO_ROOT}/deploy/tencent-lighthouse/systemd/deepseek-feishu-bridge.service" /etc/systemd/system/deepseek-feishu-bridge.service
systemctl daemon-reload
systemctl enable deepseek-runtime deepseek-feishu-bridge
cat <<'EOF'
Services installed but not started.
Before starting, verify:
/etc/deepseek/runtime.env
/etc/deepseek/feishu-bridge.env
sudo -u deepseek node /opt/deepseek/bridge/scripts/validate-config.mjs --env /etc/deepseek/feishu-bridge.env --runtime-env /etc/deepseek/runtime.env --workspace-root /opt/whalebro --check-filesystem
Then run:
sudo systemctl start deepseek-runtime
sudo systemctl start deepseek-feishu-bridge
sudo bash /opt/whalebro/deepseek-tui/scripts/tencent-lighthouse/doctor.sh
sudo journalctl -u deepseek-feishu-bridge -f
EOF