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