diff --git a/.cnb.yml b/.cnb.yml new file mode 100644 index 00000000..c8cbd37d --- /dev/null +++ b/.cnb.yml @@ -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 diff --git a/.github/workflows/sync-cnb.yml b/.github/workflows/sync-cnb.yml index f6198813..41af9f34 100644 --- a/.github/workflows/sync-cnb.yml +++ b/.github/workflows/sync-cnb.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f3ff686..d2a5c0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index cd15a463..26670df1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 83930f48..401d0ac9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 自行决定每轮需要多少模型和推理能力。 diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 57c20653..33f2693f 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -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 { /// Fetch the latest release metadata from GitHub. fn fetch_latest_release() -> Result { + 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 { + 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 { + 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://// {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 { 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, diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 5f3ff686..d2a5c0ec 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -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 diff --git a/deploy/tencent-lighthouse/cnb/README.md b/deploy/tencent-lighthouse/cnb/README.md new file mode 100644 index 00000000..c804641a --- /dev/null +++ b/deploy/tencent-lighthouse/cnb/README.md @@ -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. diff --git a/deploy/tencent-lighthouse/cnb/cnb.yml.example b/deploy/tencent-lighthouse/cnb/cnb.yml.example new file mode 100644 index 00000000..a40bc47a --- /dev/null +++ b/deploy/tencent-lighthouse/cnb/cnb.yml.example @@ -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 diff --git a/deploy/tencent-lighthouse/cnb/tag_deploy.yml.example b/deploy/tencent-lighthouse/cnb/tag_deploy.yml.example new file mode 100644 index 00000000..2ba323e2 --- /dev/null +++ b/deploy/tencent-lighthouse/cnb/tag_deploy.yml.example @@ -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 diff --git a/deploy/tencent-lighthouse/examples/feishu-bridge.env.example b/deploy/tencent-lighthouse/examples/feishu-bridge.env.example new file mode 100644 index 00000000..733b4dc9 --- /dev/null +++ b/deploy/tencent-lighthouse/examples/feishu-bridge.env.example @@ -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 diff --git a/deploy/tencent-lighthouse/examples/runtime.env.example b/deploy/tencent-lighthouse/examples/runtime.env.example new file mode 100644 index 00000000..df0330c5 --- /dev/null +++ b/deploy/tencent-lighthouse/examples/runtime.env.example @@ -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 diff --git a/deploy/tencent-lighthouse/examples/whalebro.AGENTS.md b/deploy/tencent-lighthouse/examples/whalebro.AGENTS.md new file mode 100644 index 00000000..db167943 --- /dev/null +++ b/deploy/tencent-lighthouse/examples/whalebro.AGENTS.md @@ -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. diff --git a/deploy/tencent-lighthouse/systemd/deepseek-feishu-bridge.service b/deploy/tencent-lighthouse/systemd/deepseek-feishu-bridge.service new file mode 100644 index 00000000..39f7f641 --- /dev/null +++ b/deploy/tencent-lighthouse/systemd/deepseek-feishu-bridge.service @@ -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 diff --git a/deploy/tencent-lighthouse/systemd/deepseek-runtime.service b/deploy/tencent-lighthouse/systemd/deepseek-runtime.service new file mode 100644 index 00000000..a86818c2 --- /dev/null +++ b/deploy/tencent-lighthouse/systemd/deepseek-runtime.service @@ -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 diff --git a/docs/CNB_MIRROR.md b/docs/CNB_MIRROR.md index 1b15548b..15a0b9db 100644 --- a/docs/CNB_MIRROR.md +++ b/docs/CNB_MIRROR.md @@ -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. diff --git a/docs/FEISHU_LIGHTHOUSE_V0_8_37_PLAN.md b/docs/FEISHU_LIGHTHOUSE_V0_8_37_PLAN.md new file mode 100644 index 00000000..42057d0a --- /dev/null +++ b/docs/FEISHU_LIGHTHOUSE_V0_8_37_PLAN.md @@ -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` diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 5520aec8..9d703d4b 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 diff --git a/docs/TENCENT_CLOUD_REMOTE_FIRST.md b/docs/TENCENT_CLOUD_REMOTE_FIRST.md new file mode 100644 index 00000000..155fbb06 --- /dev/null +++ b/docs/TENCENT_CLOUD_REMOTE_FIRST.md @@ -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/` diff --git a/docs/TENCENT_LIGHTHOUSE_HANDOFF_PROMPT.md b/docs/TENCENT_LIGHTHOUSE_HANDOFF_PROMPT.md new file mode 100644 index 00000000..5dba2538 --- /dev/null +++ b/docs/TENCENT_LIGHTHOUSE_HANDOFF_PROMPT.md @@ -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. +``` diff --git a/docs/TENCENT_LIGHTHOUSE_HK.md b/docs/TENCENT_LIGHTHOUSE_HK.md new file mode 100644 index 00000000..aa8e6b85 --- /dev/null +++ b/docs/TENCENT_LIGHTHOUSE_HK.md @@ -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=` 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 ` +- `/interrupt` +- `/compact` +- `/allow ` +- `/deny ` +- `/allow 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 ` for one listed thread. +5. Trigger a tool approval and verify both `/allow ` and + `/deny ` 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. diff --git a/integrations/feishu-bridge/.env.example b/integrations/feishu-bridge/.env.example new file mode 100644 index 00000000..b864bf94 --- /dev/null +++ b/integrations/feishu-bridge/.env.example @@ -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 diff --git a/integrations/feishu-bridge/README.md b/integrations/feishu-bridge/README.md new file mode 100644 index 00000000..b10da911 --- /dev/null +++ b/integrations/feishu-bridge/README.md @@ -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 ` or `/deny `. + +## 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 ` +- `/interrupt` +- `/compact` +- `/allow [remember]` +- `/deny ` + +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 +``` diff --git a/integrations/feishu-bridge/package-lock.json b/integrations/feishu-bridge/package-lock.json new file mode 100644 index 00000000..39668353 --- /dev/null +++ b/integrations/feishu-bridge/package-lock.json @@ -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 + } + } + } + } +} diff --git a/integrations/feishu-bridge/package.json b/integrations/feishu-bridge/package.json new file mode 100644 index 00000000..ee086752 --- /dev/null +++ b/integrations/feishu-bridge/package.json @@ -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" + } +} diff --git a/integrations/feishu-bridge/scripts/validate-config.mjs b/integrations/feishu-bridge/scripts/validate-config.mjs new file mode 100755 index 00000000..8ab2c8af --- /dev/null +++ b/integrations/feishu-bridge/scripts/validate-config.mjs @@ -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. +`); +} diff --git a/integrations/feishu-bridge/src/index.mjs b/integrations/feishu-bridge/src/index.mjs new file mode 100644 index 00000000..80e7a086 --- /dev/null +++ b/integrations/feishu-bridge/src/index.mjs @@ -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 "); + 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} ${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); + } +} diff --git a/integrations/feishu-bridge/src/lib.mjs b/integrations/feishu-bridge/src/lib.mjs new file mode 100644 index 00000000..d91d8088 --- /dev/null +++ b/integrations/feishu-bridge/src/lib.mjs @@ -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 - bind this chat to an existing thread", + "/interrupt - interrupt the active turn", + "/compact - compact the current thread", + "/allow [remember] - approve a pending tool call", + "/deny - deny a pending tool call", + "", + "Anything else is sent as a DeepSeek prompt." + ].join("\n"); +} diff --git a/integrations/feishu-bridge/test/lib.test.mjs b/integrations/feishu-bridge/test/lib.test.mjs new file mode 100644 index 00000000..db185384 --- /dev/null +++ b/integrations/feishu-bridge/test/lib.test.mjs @@ -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/); +}); diff --git a/scripts/tencent-lighthouse/bootstrap-ubuntu.sh b/scripts/tencent-lighthouse/bootstrap-ubuntu.sh new file mode 100755 index 00000000..07bb31fb --- /dev/null +++ b/scripts/tencent-lighthouse/bootstrap-ubuntu.sh @@ -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 </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 "$@" diff --git a/scripts/tencent-lighthouse/install-services.sh b/scripts/tencent-lighthouse/install-services.sh new file mode 100755 index 00000000..4f3add66 --- /dev/null +++ b/scripts/tencent-lighthouse/install-services.sh @@ -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