chore(release): v0.8.53 — Arcee support, telegram bridge, provider fixes

- Fix Rust syntax/clippy fallout in client.rs, cli/src/lib.rs, web_search.rs
- Fix 0.8.53 release metadata: changelog links, TUI changelog, npm wrapper
- Update visible help copy for multi-provider support
- Add telegram-bridge integration with deploy configs
- Add US remote VM quickstart doc
- Update Tencent Cloud deploy scripts and docs
- Bump npm wrapper to 0.8.53
This commit is contained in:
Hunter Bown
2026-06-03 16:12:38 -07:00
parent f884ceb6af
commit 772ec46c98
30 changed files with 2561 additions and 186 deletions
+9 -1
View File
@@ -25,6 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Provider description.** `/provider` command description is now neutral
instead of recommending specific providers.
### Community
Thanks to **@xyuai** for provider persistence, `/logout` scope clarification,
provider picker key replacement, and MiMo auth cleanup work (#2714, #2715,
#2717, #2718), and **@RefuseOdd** for configurable `path_suffix` support on
OpenAI-compatible endpoints (#2558).
## [0.8.52] - 2026-06-03
### Added
@@ -5404,7 +5411,8 @@ Welcome — and thank you.
- Hooks system and config profiles
- Example skills and launch assets
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.52...HEAD
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.53...HEAD
[0.8.53]: https://github.com/Hmbown/CodeWhale/compare/v0.8.52...v0.8.53
[0.8.52]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...v0.8.52
[0.8.51]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...v0.8.51
[0.8.50]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...v0.8.50
+2 -2
View File
@@ -135,7 +135,7 @@ enum Commands {
Run(RunArgs),
/// Run CodeWhale diagnostics.
Doctor(TuiPassthroughArgs),
/// List live DeepSeek API models via the TUI binary.
/// List live provider API models via the TUI binary.
Models(TuiPassthroughArgs),
/// Generate speech audio with Xiaomi MiMo TTS models via the TUI binary.
#[command(visible_alias = "tts")]
@@ -918,7 +918,7 @@ fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<Stri
"{:<14} {:<8} {:<10} {:<8} {}",
"provider", "config", "keyring", "env", "status"
));
lines.push(format!("{}", "-".repeat(70)));
lines.push("-".repeat(70));
for provider in PROVIDER_LIST {
let config_key = provider_config_api_key(store, provider);
+9 -1
View File
@@ -25,6 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Provider description.** `/provider` command description is now neutral
instead of recommending specific providers.
### Community
Thanks to **@xyuai** for provider persistence, `/logout` scope clarification,
provider picker key replacement, and MiMo auth cleanup work (#2714, #2715,
#2717, #2718), and **@RefuseOdd** for configurable `path_suffix` support on
OpenAI-compatible endpoints (#2558).
## [0.8.52] - 2026-06-03
### Added
@@ -5404,7 +5411,8 @@ Welcome — and thank you.
- Hooks system and config profiles
- Example skills and launch assets
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.52...HEAD
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.53...HEAD
[0.8.53]: https://github.com/Hmbown/CodeWhale/compare/v0.8.52...v0.8.53
[0.8.52]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...v0.8.52
[0.8.51]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...v0.8.51
[0.8.50]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...v0.8.50
+6 -8
View File
@@ -428,14 +428,12 @@ pub(super) fn api_url_with_suffix(base_url: &str, path: &str, path_suffix: Optio
if path.starts_with("beta/") {
return format!("{}/{}", unversioned_base_url(base_url), path);
}
if path == "chat/completions" {
if let Some(suffix) = path_suffix {
return format!(
"{}/{}",
unversioned_base_url(base_url),
suffix.trim_start_matches('/')
);
}
if let ("chat/completions", Some(suffix)) = (path, path_suffix) {
return format!(
"{}/{}",
unversioned_base_url(base_url),
suffix.trim_start_matches('/')
);
}
let mut versioned = versioned_base_url(base_url);
// The /beta suffix is not a real API version — it is an
+4 -4
View File
@@ -1,4 +1,4 @@
//! CLI entry point for the `DeepSeek` client.
//! CLI entry point for CodeWhale.
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
@@ -115,8 +115,8 @@ fn configure_windows_console_utf8() {}
bin_name = "codewhale-tui",
author,
version = env!("DEEPSEEK_BUILD_VERSION"),
about = "codewhale/CLI for DeepSeek models",
long_about = "Terminal-native TUI and CLI for DeepSeek models.\n\nRun 'codewhale' to start.\n\nNot affiliated with DeepSeek Inc."
about = "CodeWhale terminal coding agent",
long_about = "Terminal-native TUI and CLI for open-source and open-weight coding models.\n\nRun 'codewhale' to start.\n\nProvider routes include DeepSeek, Arcee, Hugging Face, OpenRouter, Xiaomi MiMo, local vLLM/SGLang/Ollama, and more."
)]
struct Cli {
/// Subcommand to run
@@ -214,7 +214,7 @@ enum Commands {
},
/// Create default AGENTS.md in current directory
Init,
/// Save a DeepSeek API key to the shared user config
/// Save an API key to the shared user config
Login {
/// API key to store (otherwise read from stdin)
#[arg(long)]
+5
View File
@@ -1905,10 +1905,15 @@ mod tests {
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn volcengine_provider_without_api_key_lists_supported_env_fallbacks() {
use crate::config::SearchProvider;
use crate::tools::spec::{ToolContext, ToolSpec};
// This test intentionally keeps the process-env lock through the
// awaited tool execution because the tool reads env fallbacks during
// that call. Dropping the lock before await would reintroduce races
// with other env-mutating tests.
let _guard = crate::test_support::lock_test_env();
let prev_volc = std::env::var_os("VOLCENGINE_API_KEY");
let prev_volc_ark = std::env::var_os("VOLCENGINE_ARK_API_KEY");
+5 -5
View File
@@ -30,22 +30,22 @@ 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`
- `CODEWHALE_REPO_BRANCH`: branch or tag to deploy, for example `main`
Optional:
- `DEEPSEEK_REPO_URL`: defaults to the CNB mirror URL
- `CODEWHALE_REPO_URL`: defaults to the CNB mirror URL
- `LIGHTHOUSE_SSH_PORT`: defaults to `22`
The server side should already have `/opt/whalebro/codewhale`,
`/etc/deepseek/runtime.env`, `/etc/deepseek/feishu-bridge.env`, and the
`/etc/codewhale/runtime.env`, `/etc/codewhale/feishu-bridge.env`, and the
`codewhale-runtime` / `codewhale-feishu-bridge` 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 store Feishu App Secret or provider API keys in CNB. They belong in
`/etc/codewhale/*.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
@@ -39,8 +39,8 @@ main:
fi
LIGHTHOUSE_SSH_PORT="${LIGHTHOUSE_SSH_PORT:-22}"
DEEPSEEK_REPO_BRANCH="${DEEPSEEK_REPO_BRANCH:-main}"
DEEPSEEK_REPO_URL="${DEEPSEEK_REPO_URL:-https://cnb.cool/codewhale.net/codewhale.git}"
CODEWHALE_REPO_BRANCH="${CODEWHALE_REPO_BRANCH:-main}"
CODEWHALE_REPO_URL="${CODEWHALE_REPO_URL:-https://cnb.cool/codewhale.net/codewhale.git}"
install -m 700 -d ~/.ssh
printf '%s\n' "$LIGHTHOUSE_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
@@ -48,11 +48,11 @@ main:
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'
"CODEWHALE_REPO_BRANCH='$CODEWHALE_REPO_BRANCH' CODEWHALE_REPO_URL='$CODEWHALE_REPO_URL' bash -s" <<'REMOTE'
set -euo pipefail
if [ ! -d /opt/whalebro/codewhale/.git ]; then
sudo -u codewhale git clone --branch "$DEEPSEEK_REPO_BRANCH" "$DEEPSEEK_REPO_URL" /opt/whalebro/codewhale
sudo -u codewhale git clone --branch "$CODEWHALE_REPO_BRANCH" "$CODEWHALE_REPO_URL" /opt/whalebro/codewhale
fi
cd /opt/whalebro/codewhale
@@ -63,12 +63,12 @@ main:
fi
sudo -u codewhale git fetch --all --tags
if sudo -u codewhale git rev-parse --verify --quiet "refs/remotes/origin/$DEEPSEEK_REPO_BRANCH" >/dev/null; then
sudo -u codewhale git checkout -B "$DEEPSEEK_REPO_BRANCH" "origin/$DEEPSEEK_REPO_BRANCH"
elif sudo -u codewhale git rev-parse --verify --quiet "refs/tags/$DEEPSEEK_REPO_BRANCH" >/dev/null; then
sudo -u codewhale git checkout --detach "$DEEPSEEK_REPO_BRANCH"
if sudo -u codewhale git rev-parse --verify --quiet "refs/remotes/origin/$CODEWHALE_REPO_BRANCH" >/dev/null; then
sudo -u codewhale git checkout -B "$CODEWHALE_REPO_BRANCH" "origin/$CODEWHALE_REPO_BRANCH"
elif sudo -u codewhale git rev-parse --verify --quiet "refs/tags/$CODEWHALE_REPO_BRANCH" >/dev/null; then
sudo -u codewhale git checkout --detach "$CODEWHALE_REPO_BRANCH"
else
sudo -u codewhale git checkout "$DEEPSEEK_REPO_BRANCH"
sudo -u codewhale git checkout "$CODEWHALE_REPO_BRANCH"
sudo -u codewhale git pull --ff-only
fi
@@ -2,20 +2,20 @@ 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
CODEWHALE_RUNTIME_URL=http://127.0.0.1:7878
CODEWHALE_RUNTIME_TOKEN=replace-with-same-token-as-runtime-env
CODEWHALE_WORKSPACE=/opt/whalebro
CODEWHALE_MODEL=auto
CODEWHALE_MODE=agent
CODEWHALE_ALLOW_SHELL=true
CODEWHALE_TRUST_MODE=false
CODEWHALE_AUTO_APPROVE=false
CODEWHALE_CHAT_ALLOWLIST=
CODEWHALE_ALLOW_UNLISTED=false
FEISHU_THREAD_MAP_PATH=/var/lib/codewhale-feishu-bridge/thread-map.json
FEISHU_ALLOW_GROUPS=false
FEISHU_REQUIRE_PREFIX_IN_GROUP=true
FEISHU_GROUP_PREFIX=/ds
FEISHU_GROUP_PREFIX=/cw
FEISHU_MAX_REPLY_CHARS=3500
DEEPSEEK_TURN_TIMEOUT_MS=900000
CODEWHALE_TURN_TIMEOUT_MS=900000
@@ -1,5 +1,6 @@
DEEPSEEK_RUNTIME_TOKEN=replace-with-long-random-token
DEEPSEEK_RUNTIME_PORT=7878
DEEPSEEK_RUNTIME_WORKERS=2
DEEPSEEK_API_KEY=replace-with-deepseek-platform-key
CODEWHALE_RUNTIME_TOKEN=replace-with-long-random-token
CODEWHALE_RUNTIME_PORT=7878
CODEWHALE_RUNTIME_WORKERS=2
CODEWHALE_PROVIDER=deepseek
DEEPSEEK_API_KEY=replace-with-provider-key
RUST_LOG=info
@@ -0,0 +1,21 @@
TELEGRAM_BOT_TOKEN=replace-with-botfather-token
CODEWHALE_RUNTIME_URL=http://127.0.0.1:7878
CODEWHALE_RUNTIME_TOKEN=replace-with-same-token-as-runtime-env
CODEWHALE_WORKSPACE=/opt/whalebro
CODEWHALE_MODEL=auto
CODEWHALE_MODE=agent
CODEWHALE_ALLOW_SHELL=true
CODEWHALE_TRUST_MODE=false
CODEWHALE_AUTO_APPROVE=false
TELEGRAM_CHAT_ALLOWLIST=
TELEGRAM_ALLOW_UNLISTED=false
TELEGRAM_THREAD_MAP_PATH=/var/lib/codewhale-telegram-bridge/thread-map.json
TELEGRAM_ALLOW_GROUPS=false
TELEGRAM_REQUIRE_PREFIX_IN_GROUP=true
TELEGRAM_GROUP_PREFIX=/cw
TELEGRAM_MAX_REPLY_CHARS=3500
TELEGRAM_POLL_TIMEOUT_SECONDS=50
CODEWHALE_TURN_TIMEOUT_MS=900000
@@ -8,7 +8,9 @@ Type=simple
User=codewhale
Group=codewhale
WorkingDirectory=/opt/codewhale/bridge
EnvironmentFile=/etc/deepseek/feishu-bridge.env
# Legacy /etc/deepseek is loaded first for old installs; /etc/codewhale wins.
EnvironmentFile=-/etc/deepseek/feishu-bridge.env
EnvironmentFile=-/etc/codewhale/feishu-bridge.env
ExecStart=/usr/bin/node /opt/codewhale/bridge/src/index.mjs
Restart=on-failure
RestartSec=5
@@ -8,14 +8,16 @@ Type=simple
User=codewhale
Group=codewhale
WorkingDirectory=/opt/whalebro
EnvironmentFile=/etc/deepseek/runtime.env
ExecStart=/home/codewhale/.cargo/bin/codewhale serve --http --host 127.0.0.1 --port ${DEEPSEEK_RUNTIME_PORT} --workers ${DEEPSEEK_RUNTIME_WORKERS} --auth-token ${DEEPSEEK_RUNTIME_TOKEN}
# Legacy /etc/deepseek is loaded first for old installs; /etc/codewhale wins.
EnvironmentFile=-/etc/deepseek/runtime.env
EnvironmentFile=-/etc/codewhale/runtime.env
ExecStart=/bin/sh -lc 'exec /home/codewhale/.cargo/bin/codewhale serve --http --host 127.0.0.1 --port "${CODEWHALE_RUNTIME_PORT:-${DEEPSEEK_RUNTIME_PORT:-7878}}" --workers "${CODEWHALE_RUNTIME_WORKERS:-${DEEPSEEK_RUNTIME_WORKERS:-2}}" --auth-token "${CODEWHALE_RUNTIME_TOKEN:-${DEEPSEEK_RUNTIME_TOKEN}}"'
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/home/codewhale/.deepseek /opt/whalebro
ReadWritePaths=/home/codewhale/.codewhale /home/codewhale/.deepseek /opt/whalebro
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,23 @@
[Unit]
Description=CodeWhale Telegram Phone Bridge
Wants=network-online.target codewhale-runtime.service
After=network-online.target codewhale-runtime.service
[Service]
Type=simple
User=codewhale
Group=codewhale
WorkingDirectory=/opt/codewhale/telegram-bridge
# Legacy /etc/deepseek is loaded first for old installs; /etc/codewhale wins.
EnvironmentFile=-/etc/deepseek/telegram-bridge.env
EnvironmentFile=-/etc/codewhale/telegram-bridge.env
ExecStart=/usr/bin/node /opt/codewhale/telegram-bridge/src/index.mjs
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/var/lib/codewhale-telegram-bridge
[Install]
WantedBy=multi-user.target
+172
View File
@@ -0,0 +1,172 @@
# US Remote VM Quickstart
This is the default remote-phone setup for US-based users who want the least
cloud ceremony. Use this path unless you specifically need the Tencent/CNB/
Feishu workflow in `docs/TENCENT_CLOUD_REMOTE_FIRST.md`.
## Recommendation
Use a small persistent Ubuntu VPS/VM, not a serverless/container platform, for
the main CodeWhale host.
Good defaults:
- DigitalOcean Droplet: 2 vCPU / 4 GB RAM / 80 GB SSD, Ubuntu.
- AWS Lightsail: 2 vCPU / 4 GB RAM / 80 GB SSD, Ubuntu.
Minimum for a smoke test:
- 2 vCPU / 2 GB RAM / 60 GB SSD.
Better for Rust builds, subagents, and longer sessions:
- 4 vCPU / 8 GB RAM / 160 GB SSD.
## Why Not Railway First
Railway is fine for a tiny relay service, but the main CodeWhale runtime wants:
- a persistent checkout and worktrees
- shell access
- systemd or equivalent long-running service supervision
- predictable local disk paths
- direct SSH recovery when the agent or bridge is unhealthy
That maps more cleanly to a VM. Use Railway later only if you want a public web
status page or a small bridge relay in front of a VM-hosted runtime.
## Provider Choice
- Choose DigitalOcean if you want the simplest VPS control panel and predictable
developer workflow.
- Choose AWS Lightsail if you already use AWS billing or want the AWS free-trial
path while staying in a simple VPS product.
- Avoid raw EC2 for the first setup unless you already know AWS networking,
IAM, security groups, and EBS.
- Avoid Lambda/ECR/Load Balancers for the first setup; they are not the
persistent interactive host CodeWhale needs.
## Target Architecture
```text
Laptop
-> git push / SSH
DigitalOcean or AWS Lightsail Ubuntu VM
-> /opt/whalebro/codewhale
-> /opt/whalebro/worktrees
-> codewhale-runtime.service on 127.0.0.1:7878
-> codewhale-telegram-bridge.service
Telegram phone DM
-> Telegram Bot API long polling
-> local runtime API with CODEWHALE_RUNTIME_TOKEN
```
The runtime API must stay on `127.0.0.1`. Telegram long polling does not need
an inbound public webhook port.
## Setup Shape
Create an Ubuntu VM with SSH-key login. Open SSH only. Then:
```bash
sudo apt-get update
sudo apt-get install -y git
export CODEWHALE_BRANCH=codex/v0.8.53
export CODEWHALE_REPO_URL=https://github.com/Hmbown/CodeWhale.git
git clone --branch "$CODEWHALE_BRANCH" "$CODEWHALE_REPO_URL" /tmp/codewhale
cd /tmp/codewhale
sudo CODEWHALE_REPO_URL="$CODEWHALE_REPO_URL" \
CODEWHALE_REPO_BRANCH="$CODEWHALE_BRANCH" \
bash scripts/tencent-lighthouse/bootstrap-ubuntu.sh
```
The bootstrap script is named for the older Tencent runbook, but it now creates
CodeWhale-primary paths and env files:
- `/etc/codewhale/runtime.env`
- `/etc/codewhale/feishu-bridge.env`
- `/opt/whalebro`
- `/opt/codewhale`
Install Rust for the `codewhale` user, then build:
```bash
sudo -iu codewhale
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/codewhale
cargo install --path crates/cli --locked --force
cargo install --path crates/tui --locked --force
exit
```
Install the Telegram bridge service:
```bash
cd /opt/whalebro/codewhale
sudo CODEWHALE_BRIDGE=telegram bash scripts/tencent-lighthouse/install-services.sh
```
Create a bot with Telegram's `@BotFather`, then edit:
```bash
sudoedit /etc/codewhale/runtime.env
sudoedit /etc/codewhale/telegram-bridge.env
```
Required values:
- `/etc/codewhale/runtime.env`
- `CODEWHALE_RUNTIME_TOKEN`
- `CODEWHALE_RUNTIME_PORT=7878`
- `CODEWHALE_PROVIDER=<provider>`
- provider API key such as `ARCEE_API_KEY`, `DEEPSEEK_API_KEY`, or
`XIAOMI_MIMO_API_KEY`
- `/etc/codewhale/telegram-bridge.env`
- `TELEGRAM_BOT_TOKEN`
- `CODEWHALE_RUNTIME_TOKEN` matching runtime.env
- `CODEWHALE_WORKSPACE=/opt/whalebro`
For first pairing, temporarily set `TELEGRAM_ALLOW_UNLISTED=true`, DM the bot
`/status`, copy the returned `chat_id` into `TELEGRAM_CHAT_ALLOWLIST`, then set
`TELEGRAM_ALLOW_UNLISTED=false`.
Validate and start:
```bash
sudo -u codewhale node /opt/codewhale/telegram-bridge/scripts/validate-config.mjs \
--env /etc/codewhale/telegram-bridge.env \
--runtime-env /etc/codewhale/runtime.env \
--workspace-root /opt/whalebro \
--check-filesystem
sudo systemctl start codewhale-runtime
sudo systemctl start codewhale-telegram-bridge
sudo CODEWHALE_BRIDGE=telegram bash /opt/whalebro/codewhale/scripts/tencent-lighthouse/doctor.sh
```
Useful logs:
```bash
sudo journalctl -u codewhale-runtime -f
sudo journalctl -u codewhale-telegram-bridge -f
```
## First Smoke Test
From Telegram:
1. Send `/status`.
2. Send `/menu` and confirm the tappable control panel appears.
3. Send `summarize git status in /opt/whalebro/codewhale`.
4. Send `/threads` and test a `Resume` button.
5. Start a prompt that requires shell approval, then test both approval buttons
and the text fallback `/allow <approval_id>` / `/deny <approval_id>`.
6. Restart the VM and confirm both services come back.
+24 -17
View File
@@ -8,6 +8,9 @@ It complements the local install path. If you only want to use `codewhale` on a
laptop, start with the README quickstart. If you want "CodeWhale as a remote
workbench I can control from my phone", start here.
For US-based users who do not need Tencent/CNB/Feishu, start with
`docs/REMOTE_VM_US.md` instead.
## Default Stack
```text
@@ -18,8 +21,8 @@ GitHub main/tags
/opt/whalebro/codewhale
/opt/whalebro/worktrees
codewhale-runtime.service on 127.0.0.1:7878
codewhale-feishu-bridge.service
-> Feishu/Lark phone DM
codewhale-feishu-bridge.service or codewhale-telegram-bridge.service
-> Feishu/Lark or Telegram phone DM
EdgeOne is optional:
public HTTPS domain -> EdgeOne -> Caddy/Nginx on Lighthouse
@@ -33,8 +36,10 @@ EdgeOne is optional:
`deploy/tencent-lighthouse/cnb/`.
- **Lighthouse** is the private always-on host. It owns `/opt/whalebro`,
systemd, Rust/Node installs, and the `codewhale 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.
- **Telegram** is the simplest phone MVP. The bridge uses long polling, so the
first setup does not need a public webhook URL.
- **Feishu/Lark** is the Tencent-native enterprise 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.
@@ -45,8 +50,8 @@ EdgeOne is optional:
2. Clone from CNB by default when the branch or tag exists there:
```bash
export DEEPSEEK_REPO_URL=https://cnb.cool/codewhale.net/codewhale.git
git ls-remote "$DEEPSEEK_REPO_URL" refs/heads/main
export CODEWHALE_REPO_URL=https://cnb.cool/codewhale.net/codewhale.git
git ls-remote "$CODEWHALE_REPO_URL" refs/heads/main
```
Tencent setup branches matching `work/v*-feishu-*` or
@@ -56,19 +61,20 @@ EdgeOne is optional:
3. Bootstrap `/opt/whalebro` on the server:
```bash
export DEEPSEEK_BRANCH=main
git clone --branch "$DEEPSEEK_BRANCH" "$DEEPSEEK_REPO_URL" /tmp/codewhale
export CODEWHALE_BRANCH=main
git clone --branch "$CODEWHALE_BRANCH" "$CODEWHALE_REPO_URL" /tmp/codewhale
cd /tmp/codewhale
sudo DEEPSEEK_REPO_URL="$DEEPSEEK_REPO_URL" \
DEEPSEEK_REPO_BRANCH="$DEEPSEEK_BRANCH" \
sudo CODEWHALE_REPO_URL="$CODEWHALE_REPO_URL" \
CODEWHALE_REPO_BRANCH="$CODEWHALE_BRANCH" \
bash scripts/tencent-lighthouse/bootstrap-ubuntu.sh
```
4. Install Rust for the `codewhale` 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.
5. Configure either a Telegram bot (`CODEWHALE_BRIDGE=telegram` and
`/etc/codewhale/telegram-bridge.env`) or a Feishu/Lark self-built app
(`CODEWHALE_BRIDGE=feishu` and `/etc/codewhale/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.
@@ -107,8 +113,8 @@ Keep these rules:
- `codewhale 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.
- `CODEWHALE_RUNTIME_TOKEN` never leaves the server env files.
- Phone-bridge 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.
@@ -122,8 +128,8 @@ Use this sequence when explaining codewhale to a new remote-first user:
sandboxing.
3. **Remote runtime:** `codewhale 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.
4. **Phone bridge:** Telegram or 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
@@ -133,5 +139,6 @@ Use this sequence when explaining codewhale to a new remote-first user:
- CNB mirror details: `docs/CNB_MIRROR.md`
- Lighthouse implementation runbook: `docs/TENCENT_LIGHTHOUSE_HK.md`
- Telegram bridge: `integrations/telegram-bridge/README.md`
- Feishu/Lark bridge: `integrations/feishu-bridge/README.md`
- CNB templates: `deploy/tencent-lighthouse/cnb/`
+52 -24
View File
@@ -1,7 +1,7 @@
# Tencent Lighthouse Hong Kong Phone Setup
This runbook sets up a Tencent Cloud Lighthouse instance in Hong Kong as an
always-on codewhale host controlled from Feishu/Lark on a phone.
always-on codewhale host controlled from Feishu/Lark or Telegram 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
@@ -13,9 +13,9 @@ is the implementation runbook for the Lighthouse host itself.
CNB mirror or GitHub branch
-> /opt/whalebro/codewhale
Feishu/Lark mobile app
-> Feishu/Lark long-connection bot
-> codewhale-feishu-bridge systemd service
Phone chat app
-> Feishu/Lark long-connection bot, or Telegram long-polling bot
-> codewhale-feishu-bridge.service or codewhale-telegram-bridge.service
-> http://127.0.0.1:7878 codewhale serve --http
-> /opt/whalebro
-> codewhale/
@@ -58,6 +58,15 @@ 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.
## Phone Bridge Choice
Use Telegram for the simplest MVP: create a bot with `@BotFather`, put the
token in `/etc/codewhale/telegram-bridge.env`, and install services with
`CODEWHALE_BRIDGE=telegram`.
Use Feishu/Lark when you specifically want the Tencent-native path, tenant
controls, or China-enterprise chat integration.
## Feishu / Lark App
Create an enterprise self-built app in:
@@ -86,12 +95,12 @@ SSH into the Lighthouse instance and run:
```bash
sudo apt-get update
sudo apt-get install -y git
export DEEPSEEK_BRANCH=main
export DEEPSEEK_REPO_URL=https://cnb.cool/codewhale.net/codewhale.git
git clone --branch "$DEEPSEEK_BRANCH" "$DEEPSEEK_REPO_URL" /tmp/codewhale
export CODEWHALE_BRANCH=main
export CODEWHALE_REPO_URL=https://cnb.cool/codewhale.net/codewhale.git
git clone --branch "$CODEWHALE_BRANCH" "$CODEWHALE_REPO_URL" /tmp/codewhale
cd /tmp/codewhale
sudo DEEPSEEK_REPO_URL="$DEEPSEEK_REPO_URL" \
DEEPSEEK_REPO_BRANCH="$DEEPSEEK_BRANCH" \
sudo CODEWHALE_REPO_URL="$CODEWHALE_REPO_URL" \
CODEWHALE_REPO_BRANCH="$CODEWHALE_BRANCH" \
bash scripts/tencent-lighthouse/bootstrap-ubuntu.sh
```
@@ -99,15 +108,15 @@ Use an SSH repo URL instead if 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/CodeWhale.git
export CODEWHALE_REPO_URL=https://github.com/Hmbown/CodeWhale.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/codewhale.net/codewhale.git
git ls-remote "$DEEPSEEK_REPO_URL" \
export CODEWHALE_REPO_URL=https://cnb.cool/codewhale.net/codewhale.git
git ls-remote "$CODEWHALE_REPO_URL" \
refs/heads/main \
refs/tags/v0.8.37
```
@@ -142,12 +151,19 @@ cd /opt/whalebro/codewhale
sudo bash scripts/tencent-lighthouse/install-services.sh
```
For Telegram instead of Feishu/Lark:
```bash
cd /opt/whalebro/codewhale
sudo CODEWHALE_BRIDGE=telegram bash scripts/tencent-lighthouse/install-services.sh
```
After editing both env files, validate the bridge/runtime pairing:
```bash
sudo -u codewhale node /opt/codewhale/bridge/scripts/validate-config.mjs \
--env /etc/deepseek/feishu-bridge.env \
--runtime-env /etc/deepseek/runtime.env \
--env /etc/codewhale/feishu-bridge.env \
--runtime-env /etc/codewhale/runtime.env \
--workspace-root /opt/whalebro \
--check-filesystem
```
@@ -158,26 +174,27 @@ 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
sudoedit /etc/codewhale/runtime.env
sudoedit /etc/codewhale/feishu-bridge.env
```
Required values:
- `/etc/deepseek/runtime.env`
- `/etc/codewhale/runtime.env`
- `CODEWHALE_PROVIDER=deepseek`
- `CODEWHALE_RUNTIME_TOKEN`
- `DEEPSEEK_API_KEY`
- `DEEPSEEK_RUNTIME_TOKEN`
- `/etc/deepseek/feishu-bridge.env`
- `/etc/codewhale/feishu-bridge.env`
- `FEISHU_APP_ID`
- `FEISHU_APP_SECRET`
- `FEISHU_DOMAIN=feishu` for Feishu, `lark` for Lark
- `DEEPSEEK_RUNTIME_TOKEN`
- `CODEWHALE_RUNTIME_TOKEN`
- `FEISHU_ALLOW_GROUPS=false` for the first deployment
For first pairing, either:
1. Temporarily set `DEEPSEEK_ALLOW_UNLISTED=true`, message the bot, copy the
returned `chat_id`, then set `DEEPSEEK_CHAT_ALLOWLIST=<chat_id>` and turn
1. Temporarily set `CODEWHALE_ALLOW_UNLISTED=true`, message the bot, copy the
returned `chat_id`, then set `CODEWHALE_CHAT_ALLOWLIST=<chat_id>` and turn
unlisted access back off.
2. Or obtain the chat ID from Feishu/Lark event logs and set the allowlist
before first start.
@@ -193,6 +210,8 @@ sudo systemctl start codewhale-feishu-bridge
sudo journalctl -u codewhale-feishu-bridge -f
```
For Telegram, use `codewhale-telegram-bridge` for the bridge service name.
Run the Lighthouse doctor after both services are configured:
```bash
@@ -200,12 +219,21 @@ cd /opt/whalebro/codewhale
sudo bash scripts/tencent-lighthouse/doctor.sh
```
For Telegram, run:
```bash
sudo CODEWHALE_BRIDGE=telegram bash scripts/tencent-lighthouse/doctor.sh
```
Enable on boot is done by `install-services.sh`; if needed:
```bash
sudo systemctl enable codewhale-runtime codewhale-feishu-bridge
```
For Telegram, enable `codewhale-telegram-bridge` instead of
`codewhale-feishu-bridge`.
## Phone Commands
DMs can be plain text and are the intended first control path:
@@ -215,7 +243,7 @@ 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`.
`FEISHU_ALLOW_GROUPS=true`, group prompts must start with `/cw`.
Useful commands:
@@ -266,7 +294,7 @@ Do not use EdgeOne to expose:
- `http://127.0.0.1:7878`
- `/v1/*` runtime endpoints
- any endpoint that accepts `DEEPSEEK_RUNTIME_TOKEN`
- any endpoint that accepts `CODEWHALE_RUNTIME_TOKEN`
## End-to-End Validation
+24
View File
@@ -0,0 +1,24 @@
TELEGRAM_BOT_TOKEN=replace-with-botfather-token
CODEWHALE_RUNTIME_URL=http://127.0.0.1:7878
CODEWHALE_RUNTIME_TOKEN=replace-with-long-random-token
CODEWHALE_WORKSPACE=/opt/whalebro
CODEWHALE_MODEL=auto
CODEWHALE_MODE=agent
CODEWHALE_ALLOW_SHELL=true
CODEWHALE_TRUST_MODE=false
CODEWHALE_AUTO_APPROVE=false
# Comma-separated Telegram chat IDs, user IDs, or usernames allowed to control
# the runtime. Leave empty only during first pairing, with
# TELEGRAM_ALLOW_UNLISTED=true.
TELEGRAM_CHAT_ALLOWLIST=
TELEGRAM_ALLOW_UNLISTED=false
TELEGRAM_THREAD_MAP_PATH=/var/lib/codewhale-telegram-bridge/thread-map.json
TELEGRAM_ALLOW_GROUPS=false
TELEGRAM_REQUIRE_PREFIX_IN_GROUP=true
TELEGRAM_GROUP_PREFIX=/cw
TELEGRAM_MAX_REPLY_CHARS=3500
TELEGRAM_POLL_TIMEOUT_SECONDS=50
CODEWHALE_TURN_TIMEOUT_MS=900000
+69
View File
@@ -0,0 +1,69 @@
# Telegram Bridge
This bridge lets a Telegram chat control a local `codewhale serve --http`
runtime from a phone. It uses Telegram Bot API long polling, so the first
version does not need a public webhook URL or inbound port.
Security model:
- `codewhale serve --http` stays bound to `127.0.0.1`.
- `/v1/*` runtime calls use `CODEWHALE_RUNTIME_TOKEN`. Legacy
`DEEPSEEK_RUNTIME_TOKEN` is accepted only as a compatibility fallback.
- Telegram chats must be allowlisted unless `TELEGRAM_ALLOW_UNLISTED=true` is
set for first pairing.
- Direct messages are the intended MVP control surface. Group chat control is
disabled unless `TELEGRAM_ALLOW_GROUPS=true`.
- Tool approvals are text commands: `/allow <approval_id>` or `/deny <approval_id>`.
- The bridge also sends inline button controls for common actions. Text
commands remain the fallback.
## Setup
Create a bot with Telegram's `@BotFather`, then configure the bridge:
```bash
cd /opt/codewhale/telegram-bridge
npm install --omit=dev
cp .env.example /etc/codewhale/telegram-bridge.env
sudoedit /etc/codewhale/telegram-bridge.env
node src/index.mjs
```
Validate env files before starting the service:
```bash
npm run validate:config -- \
--env /etc/codewhale/telegram-bridge.env \
--runtime-env /etc/codewhale/runtime.env \
--workspace-root /opt/whalebro \
--check-filesystem
```
For first pairing, temporarily set `TELEGRAM_ALLOW_UNLISTED=true`, send the bot
`/status`, copy the returned `chat_id` or `user_id` into
`TELEGRAM_CHAT_ALLOWLIST`, then turn `TELEGRAM_ALLOW_UNLISTED=false`.
## Commands
- `/menu`
- `/status`
- `/threads`
- `/new`
- `/resume <thread_id>`
- `/model <name|default>`
- `/interrupt`
- `/compact`
- `/allow <approval_id> [remember]`
- `/deny <approval_id>`
Anything else is sent as a prompt. If group control is explicitly enabled,
messages must start with `/cw` by default, for example:
```text
/cw check git status and tell me what is dirty
```
The `/menu`, `/status`, `/threads`, active-turn, and approval messages include
tap targets for common actions. Approval buttons map to the same runtime API as
`/allow` and `/deny`; they do not enable blanket auto-approval unless you tap
the explicit "Allow + remember" button.
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@codewhale/telegram-bridge",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@codewhale/telegram-bridge",
"version": "0.1.0",
"engines": {
"node": ">=18"
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@codewhale/telegram-bridge",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Telegram mobile bridge for a local codewhale 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"
},
"engines": {
"node": ">=18"
}
}
@@ -0,0 +1,164 @@
#!/usr/bin/env node
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import {
cleanEnvValue,
envFirst,
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",
requireLocalRuntime: args.requireLocalRuntime
});
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,
requireLocalRuntime: true
};
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 "--allow-remote-runtime":
parsed.requireLocalRuntime = false;
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 = envFirst(env, "CODEWHALE_WORKSPACE", "DEEPSEEK_WORKSPACE");
if (workspace) {
await checkReadableDirectory(result, workspace, "workspace");
}
const threadMapPath = cleanEnvValue(env.TELEGRAM_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 {
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.
--allow-remote-runtime Permit CODEWHALE_RUNTIME_URL to point outside localhost.
--json Print machine-readable JSON.
-h, --help Show this help.
`);
}
+919
View File
@@ -0,0 +1,919 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
activeTurnBlock,
activeTurnKeyboard,
approvalKeyboard,
callbackAction,
commandAction,
compactRuntimeError,
controlKeyboard,
envFirst,
helpText,
isAllowed,
isGroupChat,
latestRunningTurn,
looksLikePollingConflict,
pairingRefusalText,
parseBool,
parseCommand,
parseList,
preservedChatStateFields,
splitMessage,
stripGroupPrefix,
threadListKeyboard,
telegramIdentity,
telegramRetryDelayMs
} from "./lib.mjs";
class ThreadStore {
static async open(filePath) {
const store = new ThreadStore(filePath);
await store.load();
return store;
}
constructor(filePath) {
this.filePath = filePath;
this.data = { chats: {}, messages: [], actions: {} };
}
async load() {
try {
const raw = await fs.readFile(this.filePath, "utf8");
this.data = JSON.parse(raw);
if (!this.data.chats) this.data.chats = {};
if (!Array.isArray(this.data.messages)) this.data.messages = [];
if (!this.data.actions || typeof this.data.actions !== "object") this.data.actions = {};
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
}
async recordMessage(messageKey) {
if (!messageKey) return false;
if (!Array.isArray(this.data.messages)) this.data.messages = [];
if (this.data.messages.includes(messageKey)) return true;
this.data.messages.push(messageKey);
this.data.messages = this.data.messages.slice(-500);
await this.save();
return false;
}
async getChat(chatId) {
return this.data.chats[chatId] || null;
}
listChats() {
return Object.entries(this.data.chats || {});
}
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 putAction(action) {
if (!this.data.actions || typeof this.data.actions !== "object") this.data.actions = {};
const token = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
this.data.actions[token] = {
...action,
createdAt: new Date().toISOString()
};
this.pruneActions();
await this.save();
return token;
}
async getAction(token) {
if (!token || !this.data.actions) return null;
return this.data.actions[token] || null;
}
async takeAction(token) {
const action = await this.getAction(token);
if (action) {
delete this.data.actions[token];
await this.save();
}
return action;
}
pruneActions() {
const entries = Object.entries(this.data.actions || {});
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
const fresh = entries.filter(([, action]) => {
const time = Date.parse(action.createdAt || "");
return Number.isFinite(time) && time >= cutoff;
});
this.data.actions = Object.fromEntries(fresh.slice(-200));
}
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);
}
}
const config = {
botToken: requiredEnv("TELEGRAM_BOT_TOKEN"),
apiBase: (process.env.TELEGRAM_API_BASE || "https://api.telegram.org").replace(/\/+$/, ""),
runtimeUrl: (envFirst(process.env, "CODEWHALE_RUNTIME_URL", "DEEPSEEK_RUNTIME_URL") || "http://127.0.0.1:7878").replace(/\/+$/, ""),
runtimeToken: requiredEnvFirst("CODEWHALE_RUNTIME_TOKEN", "DEEPSEEK_RUNTIME_TOKEN"),
workspace: envFirst(process.env, "CODEWHALE_WORKSPACE", "DEEPSEEK_WORKSPACE") || process.cwd(),
model: envFirst(process.env, "CODEWHALE_MODEL", "DEEPSEEK_MODEL") || "auto",
mode: envFirst(process.env, "CODEWHALE_MODE", "DEEPSEEK_MODE") || "agent",
allowShell: parseBool(envFirst(process.env, "CODEWHALE_ALLOW_SHELL", "DEEPSEEK_ALLOW_SHELL"), true),
trustMode: parseBool(envFirst(process.env, "CODEWHALE_TRUST_MODE", "DEEPSEEK_TRUST_MODE"), false),
autoApprove: parseBool(envFirst(process.env, "CODEWHALE_AUTO_APPROVE", "DEEPSEEK_AUTO_APPROVE"), false),
allowlist: parseList(
envFirst(process.env, "TELEGRAM_CHAT_ALLOWLIST", "CODEWHALE_CHAT_ALLOWLIST", "DEEPSEEK_CHAT_ALLOWLIST")
),
allowUnlisted: parseBool(
envFirst(process.env, "TELEGRAM_ALLOW_UNLISTED", "CODEWHALE_ALLOW_UNLISTED", "DEEPSEEK_ALLOW_UNLISTED"),
false
),
threadMapPath:
process.env.TELEGRAM_THREAD_MAP_PATH ||
"/var/lib/codewhale-telegram-bridge/thread-map.json",
allowGroups: parseBool(process.env.TELEGRAM_ALLOW_GROUPS, false),
requirePrefixInGroup: parseBool(process.env.TELEGRAM_REQUIRE_PREFIX_IN_GROUP, true),
groupPrefix: process.env.TELEGRAM_GROUP_PREFIX || "/cw",
maxReplyChars: Math.min(Number(process.env.TELEGRAM_MAX_REPLY_CHARS || 3500), 4096),
pollTimeoutSeconds: Number(process.env.TELEGRAM_POLL_TIMEOUT_SECONDS || 50),
turnTimeoutMs: Number(envFirst(process.env, "CODEWHALE_TURN_TIMEOUT_MS", "DEEPSEEK_TURN_TIMEOUT_MS") || 900000)
};
const threadStore = await ThreadStore.open(config.threadMapPath);
let stopping = false;
let updateOffset = Number(process.env.TELEGRAM_UPDATE_OFFSET || 0);
process.once("SIGINT", () => {
stopping = true;
});
process.once("SIGTERM", () => {
stopping = true;
});
console.log("Starting CodeWhale Telegram 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.");
}
await configureBotCommands().catch((error) => {
console.error("failed to configure Telegram bot command menu", error);
});
void reattachActiveTurns().catch((error) => {
console.error("failed to reattach active Telegram bridge turns", error);
});
await pollTelegram();
async function configureBotCommands() {
await telegramApi("setMyCommands", {
commands: [
{ command: "menu", description: "Open CodeWhale controls" },
{ command: "status", description: "Show runtime and workspace status" },
{ command: "threads", description: "List recent runtime threads" },
{ command: "new", description: "Create a new thread" },
{ command: "interrupt", description: "Interrupt the active turn" },
{ command: "compact", description: "Compact the current thread" },
{ command: "help", description: "Show command help" }
]
});
}
async function pollTelegram() {
while (!stopping) {
try {
const updates = await telegramApi("getUpdates", {
offset: updateOffset || undefined,
timeout: config.pollTimeoutSeconds,
allowed_updates: ["message", "callback_query"]
});
for (const update of updates || []) {
if (update.update_id != null) updateOffset = Math.max(updateOffset, update.update_id + 1);
await handleIncomingUpdate(update).catch((error) => {
console.error("failed to handle incoming Telegram update", error);
});
}
} catch (error) {
if (looksLikePollingConflict(error)) {
console.warn("Telegram getUpdates conflict; another bridge is polling this bot. Retrying in 10s.");
await delay(10000);
continue;
}
const waitMs = telegramRetryDelayMs(error);
console.error(`Telegram poll failed: ${error.message}. Retrying in ${Math.round(waitMs / 1000)}s.`);
await delay(waitMs);
}
}
}
async function handleIncomingUpdate(update) {
if (update.callback_query) {
await handleCallbackQuery(update.callback_query);
return;
}
const identity = telegramIdentity(update);
if (!identity.chatId || !identity.messageId) return;
if (identity.isBot) return;
const messageKey = `${identity.chatId}:${identity.messageId}`;
if (await threadStore.recordMessage(messageKey)) return;
if (!identity.text) {
await sendText(identity.chatId, "Only text messages are supported in this first bridge.");
return;
}
const scoped = stripGroupPrefix(identity.text, {
chatType: identity.chatType,
requirePrefix: config.requirePrefixInGroup,
prefix: config.groupPrefix
});
if (!scoped.accepted) return;
if (isGroupChat(identity.chatType) && !config.allowGroups) {
await sendText(
identity.chatId,
"Group chat control is disabled for this bridge. DM the bot, or set TELEGRAM_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(), { replyMarkup: controlKeyboard() });
return;
case "menu":
await sendMenu(chatId);
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}`, { replyMarkup: controlKeyboard() });
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 "set_model":
await setChatModel(chatId, action.modelName);
return;
case "prompt":
await runPrompt(chatId, action.prompt);
return;
default:
await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
}
}
async function handleCallbackQuery(query) {
const chat = query.message?.chat || {};
const from = query.from || {};
const identity = {
chatId: chat.id != null ? String(chat.id) : "",
messageId: query.message?.message_id != null ? String(query.message.message_id) : "",
chatType: chat.type || "",
userId: from.id != null ? String(from.id) : "",
username: from.username ? `@${from.username}` : "",
firstName: from.first_name || "",
isBot: Boolean(from.is_bot)
};
if (!identity.chatId || !query.id) return;
if (identity.isBot) return;
if (isGroupChat(identity.chatType) && !config.allowGroups) {
await answerCallback(query.id, "Group control is disabled.");
return;
}
if (!isAllowed(identity, config.allowlist, config.allowUnlisted)) {
await answerCallback(query.id, "This chat is not allowlisted.");
return;
}
const action = callbackAction(query.data);
if (!action) {
await answerCallback(query.id, "Unknown action.");
return;
}
await answerCallback(query.id, "Working...");
await handleModalAction(identity.chatId, action, query);
}
async function handleModalAction(chatId, action, query = null) {
switch (action.kind) {
case "help":
await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
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}`, { replyMarkup: controlKeyboard() });
return;
}
case "interrupt":
await interruptActiveTurn(chatId);
return;
case "compact":
await compactThread(chatId);
return;
case "set_model":
await setChatModel(chatId, action.modelName);
return;
case "stored_action":
await handleStoredAction(chatId, action, query);
return;
default:
await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
}
}
async function handleStoredAction(chatId, action, query = null) {
const stored = await threadStore.getAction(action.token);
if (!stored) {
await sendText(chatId, "That action expired. Open /menu and try again.");
return;
}
if (stored.kind === "resume") {
await resumeThread(chatId, stored.threadId);
return;
}
if (stored.kind === "approval") {
const suffix = action.suffix || "";
const decision = suffix === "deny" ? "deny" : "allow";
const remember = suffix === "remember";
await threadStore.takeAction(action.token);
await decideApproval(chatId, {
decision,
approvalId: stored.approvalId,
remember
});
if (query?.message?.message_id) {
await editMessageReplyMarkup(chatId, query.message.message_id, null).catch(() => {});
}
return;
}
await sendText(chatId, "That action is no longer supported.");
}
async function sendMenu(chatId) {
const state = await threadStore.getChat(chatId);
await sendText(
chatId,
[
"CodeWhale controls",
state?.threadId ? `thread=${state.threadId}` : "thread=(new on first prompt)",
`model=${state?.model || config.model}`
].join("\n"),
{ replyMarkup: controlKeyboard() }
);
}
async function ensureThread(chatId, { forceNew = false } = {}) {
const existing = await threadStore.getChat(chatId);
if (existing?.threadId && !forceNew) return existing;
const effectiveModel = existing?.model || config.model;
const thread = await runtimeJson("/v1/threads", {
method: "POST",
body: {
model: effectiveModel,
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 Telegram phone chat. Keep status updates concise. Ask for tool approvals when needed; do not assume mobile messages imply blanket approval."
}
});
const state = {
...preservedChatStateFields(existing),
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(), { replyMarkup: controlKeyboard() });
return;
}
const state = await ensureThread(chatId);
const effectiveModel = state?.model || config.model;
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, { replyMarkup: activeTurnKeyboard() });
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: effectiveModel,
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)"}`, {
replyMarkup: activeTurnKeyboard()
});
try {
await streamTurnEvents(chatId, state.threadId, turnId, sinceSeq);
} finally {
await threadStore.patchChat(chatId, {
activeTurnId: null,
updatedAt: new Date().toISOString()
});
}
}
async function reattachActiveTurns() {
for (const [chatId, state] of threadStore.listChats()) {
if (!state?.threadId || !state.activeTurnId) continue;
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
const runningTurn = latestRunningTurn(detail);
if (!runningTurn) {
await threadStore.patchChat(chatId, {
activeTurnId: null,
lastSeq: Number(detail.latest_seq || state.lastSeq || 0),
updatedAt: new Date().toISOString()
});
await sendText(chatId, `Bridge restarted. No active turn remains for ${state.threadId}.`);
continue;
}
const turnId = runningTurn.id || state.activeTurnId;
const sinceSeq = Number(state.lastSeq || 0);
await threadStore.patchChat(chatId, {
activeTurnId: turnId,
updatedAt: new Date().toISOString()
});
await sendText(
chatId,
`Bridge restarted. Reattaching to active turn ${turnId} from seq ${sinceSeq}.`
);
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 || {};
const approvalId = approval.approval_id || approval.id;
if (!approvalId) {
await sendText(
chatId,
[
"Approval required",
`tool=${approval.tool_name || "unknown"}`,
approval.description || "",
"",
"No approval_id was provided by the runtime; use /status and retry from the TUI."
]
.filter(Boolean)
.join("\n"),
{ replyMarkup: controlKeyboard() }
);
continue;
}
const actionToken = await threadStore.putAction({
kind: "approval",
approvalId
});
await sendText(
chatId,
[
"Approval required",
`tool=${approval.tool_name || "unknown"}`,
`approval_id=${approvalId}`,
approval.description || "",
"",
`Tap a button, or reply /allow ${approvalId}`,
`Reply /deny ${approvalId}`
]
.filter(Boolean)
.join("\n"),
{ replyMarkup: approvalKeyboard(actionToken) }
);
}
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(), {
replyMarkup: controlKeyboard()
});
} else {
await sendText(chatId, responseText.trim() || "Turn completed.", {
replyMarkup: controlKeyboard()
});
}
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}.`, { replyMarkup: controlKeyboard() });
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"),
{ replyMarkup: controlKeyboard() }
);
}
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.", { replyMarkup: controlKeyboard() });
return;
}
const actions = [];
for (const [index, thread] of threads.slice(0, 8).entries()) {
const token = await threadStore.putAction({
kind: "resume",
threadId: thread.id
});
actions.push({ token, label: `Resume ${index + 1}` });
}
await sendText(
chatId,
threads
.map((thread, index) => {
const status = thread.latest_turn_status || "none";
return `${index + 1}. ${thread.id} [${status}] ${thread.title || thread.preview || ""}`;
})
.join("\n"),
{ replyMarkup: threadListKeyboard(actions) }
);
}
async function resumeThread(chatId, args) {
const threadId = args.trim();
if (!threadId) {
await sendText(chatId, "Usage: /resume <thread_id>");
return;
}
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`);
const existing = await threadStore.getChat(chatId);
await threadStore.setChat(chatId, {
...preservedChatStateFields(existing),
threadId,
lastSeq: Number(detail.latest_seq || 0),
activeTurnId: null,
updatedAt: new Date().toISOString()
});
await sendText(chatId, `Resumed thread ${threadId}`, { replyMarkup: controlKeyboard() });
}
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}`, { replyMarkup: controlKeyboard() });
}
async function compactThread(chatId) {
const state = await ensureThread(chatId);
const result = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}/compact`, {
method: "POST",
body: { reason: "telegram bridge request" }
});
await sendText(chatId, `Compaction started: ${result.turn?.id || "unknown turn"}`, {
replyMarkup: activeTurnKeyboard()
});
}
async function decideApproval(chatId, action) {
const decision = action.decision;
const { approvalId, remember } = action;
if (!approvalId) {
await sendText(
chatId,
`Usage: /${decision} <approval_id>${decision === "allow" ? " [remember]" : ""}`
);
return;
}
await runtimeJson(`/v1/approvals/${encodeURIComponent(approvalId)}`, {
method: "POST",
body: { decision, remember }
});
await sendText(chatId, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`);
}
async function setChatModel(chatId, modelName) {
if (!modelName || modelName === "default") {
await threadStore.patchChat(chatId, {
model: null,
updatedAt: new Date().toISOString()
});
await sendText(chatId, `Reset per-chat model. Using bridge default: ${config.model}`, {
replyMarkup: controlKeyboard()
});
return;
}
await threadStore.patchChat(chatId, {
model: modelName,
updatedAt: new Date().toISOString()
});
await sendText(chatId, `Per-chat model set to: ${modelName}`, { replyMarkup: controlKeyboard() });
}
async function sendText(chatId, text, options = {}) {
const chunks = splitMessage(text, config.maxReplyChars);
for (const [index, chunk] of chunks.entries()) {
const body = {
chat_id: chatId,
text: chunk,
disable_web_page_preview: true
};
if (options.replyMarkup && index === chunks.length - 1) {
body.reply_markup = options.replyMarkup;
}
await telegramApi("sendMessage", body);
}
}
async function answerCallback(callbackQueryId, text = "") {
await telegramApi("answerCallbackQuery", {
callback_query_id: callbackQueryId,
text: text.slice(0, 200),
show_alert: false
});
}
async function editMessageReplyMarkup(chatId, messageId, replyMarkup) {
await telegramApi("editMessageReplyMarkup", {
chat_id: chatId,
message_id: messageId,
reply_markup: replyMarkup
});
}
async function telegramApi(method, body = {}) {
const response = await fetch(`${config.apiBase}/bot${config.botToken}/${method}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
const payload = await readJsonSafe(response);
if (!response.ok || payload?.ok === false) {
const error = new Error(
payload?.description || `Telegram API request failed (${response.status})`
);
error.errorCode = payload?.error_code || response.status;
error.description = payload?.description || "";
error.parameters = payload?.parameters || {};
throw error;
}
return payload.result;
}
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 requiredEnvFirst(...names) {
const value = envFirst(process.env, ...names);
if (!value) {
throw new Error(`${names.join(" or ")} is required`);
}
return value;
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+461
View File
@@ -0,0 +1,461 @@
export function envFirst(env, ...names) {
for (const name of names) {
const value = env?.[name];
if (value != null && String(value).trim()) return String(value).trim();
}
return "";
}
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 telegramIdentity(update) {
const message = update?.message || update?.edited_message || {};
const chat = message.chat || {};
const from = message.from || {};
const username = from.username ? `@${from.username}` : "";
return {
updateId: update?.update_id ?? null,
chatId: chat.id != null ? String(chat.id) : "",
messageId: message.message_id != null ? String(message.message_id) : "",
chatType: chat.type || "",
userId: from.id != null ? String(from.id) : "",
username,
firstName: from.first_name || "",
text: typeof message.text === "string" ? message.text : "",
isBot: Boolean(from.is_bot)
};
}
export function isGroupChat(chatType) {
return chatType === "group" || chatType === "supergroup";
}
export function isAllowed(identity, allowlist, allowUnlisted = false) {
if (allowUnlisted) return true;
const allowed = new Set(allowlist);
return [identity.chatId, identity.userId, identity.username]
.filter(Boolean)
.some((id) => allowed.has(id));
}
export function pairingRefusalText(identity) {
return [
"This Telegram chat is not in TELEGRAM_CHAT_ALLOWLIST.",
`chat_id=${identity.chatId}`,
identity.userId ? `user_id=${identity.userId}` : "",
identity.username ? `username=${identity.username}` : "",
"",
"For first pairing, add one of those IDs to TELEGRAM_CHAT_ALLOWLIST, or temporarily set TELEGRAM_ALLOW_UNLISTED=true."
]
.filter(Boolean)
.join("\n");
}
export function stripGroupPrefix(text, { chatType, requirePrefix, prefix }) {
const trimmed = String(text || "").trim();
if (!trimmed) return { accepted: false, text: "" };
if (!requirePrefix || !isGroupChat(chatType)) {
return { accepted: true, text: trimmed };
}
const marker = prefix || "/cw";
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+/);
const name = head
.slice(1)
.split("@")[0]
.toLowerCase();
return {
name,
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 "start":
case "help":
return { kind: "help" };
case "menu":
return { kind: "menu" };
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 "model":
return { kind: "set_model", modelName: command.args };
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 controlKeyboard() {
return {
inline_keyboard: [
[
{ text: "Status", callback_data: "cw:status" },
{ text: "New thread", callback_data: "cw:new" }
],
[
{ text: "Threads", callback_data: "cw:threads" },
{ text: "Interrupt", callback_data: "cw:interrupt" }
],
[
{ text: "Compact", callback_data: "cw:compact" },
{ text: "Reset model", callback_data: "cw:model:default" }
],
[{ text: "Help", callback_data: "cw:help" }]
]
};
}
export function activeTurnKeyboard() {
return {
inline_keyboard: [
[
{ text: "Status", callback_data: "cw:status" },
{ text: "Interrupt", callback_data: "cw:interrupt" }
],
[{ text: "Threads", callback_data: "cw:threads" }]
]
};
}
export function approvalKeyboard(actionToken) {
return {
inline_keyboard: [
[
{ text: "Allow once", callback_data: `cw:act:${actionToken}` },
{ text: "Allow + remember", callback_data: `cw:act:${actionToken}:remember` }
],
[{ text: "Deny", callback_data: `cw:act:${actionToken}:deny` }]
]
};
}
export function threadListKeyboard(threadActions) {
const rows = [];
for (const action of threadActions.slice(0, 8)) {
rows.push([{ text: action.label, callback_data: `cw:act:${action.token}` }]);
}
rows.push([{ text: "New thread", callback_data: "cw:new" }]);
return { inline_keyboard: rows };
}
export function callbackAction(data) {
const value = String(data || "");
switch (value) {
case "cw:status":
return { kind: "status" };
case "cw:new":
return { kind: "new_thread" };
case "cw:threads":
return { kind: "threads" };
case "cw:interrupt":
return { kind: "interrupt" };
case "cw:compact":
return { kind: "compact" };
case "cw:help":
return { kind: "help" };
case "cw:model:default":
return { kind: "set_model", modelName: "default" };
default:
break;
}
if (value.startsWith("cw:act:")) {
const [, , token, suffix] = value.split(":", 4);
return { kind: "stored_action", token: token || "", suffix: suffix || "" };
}
return null;
}
export function preservedChatStateFields(state = {}) {
const preserved = {};
if (Object.prototype.hasOwnProperty.call(state || {}, "model")) {
preserved.model = state.model || null;
}
return preserved;
}
export function splitMessage(text, maxChars = 3500) {
const value = String(text || "");
const chars = Array.from(value);
if (chars.length <= maxChars) return value ? [value] : [];
const chunks = [];
let cursor = 0;
while (cursor < chars.length) {
chunks.push(chars.slice(cursor, cursor + maxChars).join(""));
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 telegramRetryDelayMs(error, fallbackMs = 3000) {
const retryAfter = Number(error?.parameters?.retry_after || 0);
if (Number.isFinite(retryAfter) && retryAfter > 0) {
return Math.min(retryAfter * 1000, 60000);
}
return fallbackMs;
}
export function looksLikePollingConflict(error) {
const text = String(error?.description || error?.message || "").toLowerCase();
return error?.errorCode === 409 || text.includes("terminated by other getupdates request");
}
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 });
const botToken = envFirst(env, "TELEGRAM_BOT_TOKEN");
if (!botToken) {
add(errors, "missing_required", "TELEGRAM_BOT_TOKEN is required");
} else if (isPlaceholderValue(botToken)) {
add(errors, "placeholder_value", "TELEGRAM_BOT_TOKEN still contains a placeholder value");
}
const runtimeUrl = envFirst(env, "CODEWHALE_RUNTIME_URL", "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", "CODEWHALE_RUNTIME_URL must use http or https");
}
if (!localHosts.has(parsed.hostname) && options.requireLocalRuntime !== false) {
add(errors, "remote_runtime_url", "CODEWHALE_RUNTIME_URL should point at localhost on a VM deployment");
}
} catch {
add(errors, "invalid_runtime_url", "CODEWHALE_RUNTIME_URL is not a valid URL");
}
const runtimeToken = envFirst(env, "CODEWHALE_RUNTIME_TOKEN", "DEEPSEEK_RUNTIME_TOKEN");
if (!runtimeToken) {
add(errors, "missing_required", "CODEWHALE_RUNTIME_TOKEN is required");
} else if (isPlaceholderValue(runtimeToken)) {
add(errors, "placeholder_value", "CODEWHALE_RUNTIME_TOKEN still contains a placeholder value");
}
const workspace = envFirst(env, "CODEWHALE_WORKSPACE", "DEEPSEEK_WORKSPACE");
if (workspace && !workspace.startsWith("/")) {
add(errors, "relative_workspace", "CODEWHALE_WORKSPACE must be an absolute path");
}
if (
workspace &&
workspaceRoot &&
workspace !== workspaceRoot &&
!workspace.startsWith(`${workspaceRoot}/`)
) {
add(warnings, "workspace_root", `CODEWHALE_WORKSPACE is outside ${workspaceRoot}`);
}
const threadMapPath = envFirst(env, "TELEGRAM_THREAD_MAP_PATH");
if (threadMapPath && !threadMapPath.startsWith("/")) {
add(errors, "relative_thread_map", "TELEGRAM_THREAD_MAP_PATH must be an absolute path");
}
const allowGroups = parseBool(env.TELEGRAM_ALLOW_GROUPS, false);
const requirePrefix = parseBool(env.TELEGRAM_REQUIRE_PREFIX_IN_GROUP, true);
const allowUnlisted = parseBool(
envFirst(env, "TELEGRAM_ALLOW_UNLISTED", "CODEWHALE_ALLOW_UNLISTED", "DEEPSEEK_ALLOW_UNLISTED"),
false
);
const allowlist = parseList(
envFirst(env, "TELEGRAM_CHAT_ALLOWLIST", "CODEWHALE_CHAT_ALLOWLIST", "DEEPSEEK_CHAT_ALLOWLIST")
);
if (!allowlist.length && allowUnlisted) {
add(warnings, "pairing_mode_open", "TELEGRAM_ALLOW_UNLISTED=true leaves first-pairing mode open");
} else if (!allowlist.length) {
add(warnings, "not_paired", "TELEGRAM_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 TELEGRAM_GROUP_PREFIX");
}
if (!allowGroups) {
add(info, "dm_only", "Direct-message control is enabled; group chats are disabled");
}
const maxReplyChars = Number(env.TELEGRAM_MAX_REPLY_CHARS || 3500);
if (!Number.isFinite(maxReplyChars) || maxReplyChars < 100 || maxReplyChars > 4096) {
add(errors, "invalid_max_reply_chars", "TELEGRAM_MAX_REPLY_CHARS must be between 100 and 4096");
}
const pollTimeout = Number(env.TELEGRAM_POLL_TIMEOUT_SECONDS || 50);
if (!Number.isFinite(pollTimeout) || pollTimeout < 1 || pollTimeout > 60) {
add(errors, "invalid_poll_timeout", "TELEGRAM_POLL_TIMEOUT_SECONDS must be between 1 and 60");
}
const turnTimeoutMs = Number(envFirst(env, "CODEWHALE_TURN_TIMEOUT_MS", "DEEPSEEK_TURN_TIMEOUT_MS") || 900000);
if (!Number.isFinite(turnTimeoutMs) || turnTimeoutMs < 1000) {
add(errors, "invalid_turn_timeout", "CODEWHALE_TURN_TIMEOUT_MS must be at least 1000");
}
if (runtimeEnv) {
const runtimeFileToken = envFirst(runtimeEnv, "CODEWHALE_RUNTIME_TOKEN", "DEEPSEEK_RUNTIME_TOKEN");
if (!runtimeFileToken) {
add(errors, "missing_runtime_token", "runtime.env is missing CODEWHALE_RUNTIME_TOKEN");
} else if (isPlaceholderValue(runtimeFileToken)) {
add(errors, "placeholder_runtime_token", "runtime.env CODEWHALE_RUNTIME_TOKEN is still a placeholder");
} else if (runtimeToken && runtimeToken !== runtimeFileToken) {
add(errors, "token_mismatch", "Runtime and bridge token values do not match");
}
const provider = envFirst(runtimeEnv, "CODEWHALE_PROVIDER", "DEEPSEEK_PROVIDER");
if (!provider) {
add(warnings, "missing_provider", "runtime.env does not set CODEWHALE_PROVIDER");
}
const runtimePort = Number(envFirst(runtimeEnv, "CODEWHALE_RUNTIME_PORT", "DEEPSEEK_RUNTIME_PORT") || 7878);
if (!Number.isInteger(runtimePort) || runtimePort <= 0 || runtimePort > 65535) {
add(errors, "invalid_runtime_port", "runtime port must be a valid TCP port");
}
}
return {
ok: errors.length === 0,
errors,
warnings,
info
};
}
export function formatValidationReport(result) {
const lines = ["Telegram 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 [
"CodeWhale Telegram bridge commands:",
"/menu - open tappable controls",
"/help - show this help",
"/status - runtime and workspace status",
"/threads - recent runtime threads",
"/new - create a new thread for this chat",
"/resume <thread_id> - bind this chat to an existing thread",
"/model <name|default> - set or reset this chat's model",
"/interrupt - interrupt the active turn",
"/compact - compact the current thread",
"/allow <approval_id> [remember] - approve a pending tool call",
"/deny <approval_id> - deny a pending tool call",
"",
"Anything else is sent as a CodeWhale prompt."
].join("\n");
}
@@ -0,0 +1,304 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
activeTurnBlock,
activeTurnKeyboard,
approvalKeyboard,
callbackAction,
commandAction,
controlKeyboard,
envFirst,
helpText,
isAllowed,
pairingRefusalText,
parseApprovalDecisionArgs,
parseBool,
parseCommand,
parseEnvText,
parseList,
preservedChatStateFields,
splitMessage,
stripGroupPrefix,
threadListKeyboard,
telegramIdentity,
telegramRetryDelayMs,
looksLikePollingConflict,
validateBridgeConfig
} from "../src/lib.mjs";
test("envFirst returns first non-empty value", () => {
assert.equal(envFirst({ A: "", B: " value " }, "A", "B"), "value");
assert.equal(envFirst({ A: "x" }, "B"), "");
});
test("parseList trims empty values", () => {
assert.deepEqual(parseList(" 123, @user ,, "), ["123", "@user"]);
});
test("parseBool accepts common truthy values", () => {
assert.equal(parseBool("yes"), true);
assert.equal(parseBool("0", true), false);
assert.equal(parseBool(undefined, true), true);
});
test("parseEnvText handles comments, export, and quoted values", () => {
assert.deepEqual(
parseEnvText(`
# ignored
export TELEGRAM_GROUP_PREFIX="/cw"
CODEWHALE_WORKSPACE='/opt/whalebro'
`),
{
TELEGRAM_GROUP_PREFIX: "/cw",
CODEWHALE_WORKSPACE: "/opt/whalebro"
}
);
});
test("telegramIdentity extracts chat and sender identifiers", () => {
const identity = telegramIdentity({
update_id: 10,
message: {
message_id: 20,
text: "hello",
chat: { id: -1001, type: "supergroup" },
from: { id: 42, username: "hunter", first_name: "Hunter" }
}
});
assert.deepEqual(identity, {
updateId: 10,
chatId: "-1001",
messageId: "20",
chatType: "supergroup",
userId: "42",
username: "@hunter",
firstName: "Hunter",
text: "hello",
isBot: false
});
});
test("stripGroupPrefix requires prefix in Telegram groups", () => {
assert.deepEqual(
stripGroupPrefix("/cw inspect this", {
chatType: "group",
requirePrefix: true,
prefix: "/cw"
}),
{ accepted: true, text: "inspect this" }
);
assert.equal(
stripGroupPrefix("inspect this", {
chatType: "group",
requirePrefix: true,
prefix: "/cw"
}).accepted,
false
);
});
test("stripGroupPrefix accepts private chat text without group prefix", () => {
assert.deepEqual(
stripGroupPrefix("inspect this", {
chatType: "private",
requirePrefix: true,
prefix: "/cw"
}),
{ accepted: true, text: "inspect this" }
);
});
test("parseCommand handles Telegram bot mentions", () => {
assert.deepEqual(parseCommand("hello"), { name: "prompt", args: "hello" });
assert.deepEqual(parseCommand("/allow@CodeWhaleBot abc remember"), {
name: "allow",
args: "abc remember"
});
});
test("commandAction maps bridge commands and falls back to prompts", () => {
assert.deepEqual(commandAction(parseCommand("/menu")), { kind: "menu" });
assert.deepEqual(commandAction(parseCommand("/status")), { kind: "status" });
assert.deepEqual(commandAction(parseCommand("/resume thread-1")), {
kind: "resume",
threadId: "thread-1"
});
assert.deepEqual(commandAction(parseCommand("/model arcee-trinity")), {
kind: "set_model",
modelName: "arcee-trinity"
});
assert.deepEqual(commandAction(parseCommand("/unknown value")), {
kind: "prompt",
prompt: "/unknown value"
});
});
test("helpText documents per-chat model switching", () => {
assert.match(helpText(), /\/model <name\|default>/);
assert.match(helpText(), /\/menu/);
});
test("control keyboards expose modal actions", () => {
assert.deepEqual(controlKeyboard().inline_keyboard[0][0], {
text: "Status",
callback_data: "cw:status"
});
assert.deepEqual(activeTurnKeyboard().inline_keyboard[0][1], {
text: "Interrupt",
callback_data: "cw:interrupt"
});
assert.deepEqual(approvalKeyboard("tok1").inline_keyboard[1][0], {
text: "Deny",
callback_data: "cw:act:tok1:deny"
});
assert.deepEqual(threadListKeyboard([{ token: "t1", label: "Resume 1" }]).inline_keyboard[0][0], {
text: "Resume 1",
callback_data: "cw:act:t1"
});
});
test("callbackAction parses modal callback payloads", () => {
assert.deepEqual(callbackAction("cw:status"), { kind: "status" });
assert.deepEqual(callbackAction("cw:model:default"), {
kind: "set_model",
modelName: "default"
});
assert.deepEqual(callbackAction("cw:act:tok1:remember"), {
kind: "stored_action",
token: "tok1",
suffix: "remember"
});
assert.equal(callbackAction("unknown"), null);
});
test("preservedChatStateFields carries model across state replacement", () => {
assert.deepEqual(
preservedChatStateFields({
threadId: "old-thread",
model: "mimo-v2.5-pro",
activeTurnId: "turn-1"
}),
{
model: "mimo-v2.5-pro"
}
);
assert.deepEqual(preservedChatStateFields({ model: null }), { model: null });
});
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 Telegram chat/user/username identifiers", () => {
assert.equal(
isAllowed({ chatId: "-1001", userId: "42", username: "@hunter" }, ["42"], false),
true
);
assert.equal(isAllowed({ chatId: "-1001" }, [], false), false);
assert.equal(isAllowed({ chatId: "-1001" }, [], true), true);
});
test("pairingRefusalText includes allowlist identifiers", () => {
const body = pairingRefusalText({
chatId: "-1001",
userId: "42",
username: "@hunter"
});
assert.match(body, /chat_id=-1001/);
assert.match(body, /user_id=42/);
assert.match(body, /username=@hunter/);
});
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: "queued" }
]
}),
{
turnId: "turn-2",
message: "Thread already has active turn turn-2. Wait for it to finish or send /interrupt."
}
);
});
test("splitMessage chunks long text without splitting surrogate pairs", () => {
assert.deepEqual(splitMessage("a🧪b", 2), ["a🧪", "b"]);
});
test("telegramRetryDelayMs honors retry_after", () => {
assert.equal(telegramRetryDelayMs({ parameters: { retry_after: 2 } }), 2000);
});
test("looksLikePollingConflict detects Telegram 409 conflicts", () => {
assert.equal(looksLikePollingConflict({ errorCode: 409 }), true);
assert.equal(
looksLikePollingConflict({
message: "Conflict: terminated by other getUpdates request"
}),
true
);
});
test("validateBridgeConfig accepts locked-down whalebro DM config", () => {
const result = validateBridgeConfig(
{
TELEGRAM_BOT_TOKEN: "123456:token",
CODEWHALE_RUNTIME_URL: "http://127.0.0.1:7878",
CODEWHALE_RUNTIME_TOKEN: "token-a",
CODEWHALE_WORKSPACE: "/opt/whalebro",
TELEGRAM_CHAT_ALLOWLIST: "42",
TELEGRAM_ALLOW_UNLISTED: "false",
TELEGRAM_THREAD_MAP_PATH: "/var/lib/codewhale-telegram-bridge/thread-map.json",
TELEGRAM_ALLOW_GROUPS: "false",
TELEGRAM_REQUIRE_PREFIX_IN_GROUP: "true"
},
{
workspaceRoot: "/opt/whalebro",
runtimeEnv: {
CODEWHALE_RUNTIME_TOKEN: "token-a",
CODEWHALE_PROVIDER: "arcee",
CODEWHALE_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(
{
TELEGRAM_BOT_TOKEN: "123456:token",
CODEWHALE_RUNTIME_URL: "http://127.0.0.1:7878",
CODEWHALE_RUNTIME_TOKEN: "bridge-token",
CODEWHALE_WORKSPACE: "/opt/whalebro",
TELEGRAM_ALLOW_UNLISTED: "true",
TELEGRAM_THREAD_MAP_PATH: "/var/lib/codewhale-telegram-bridge/thread-map.json",
TELEGRAM_ALLOW_GROUPS: "true",
TELEGRAM_REQUIRE_PREFIX_IN_GROUP: "false"
},
{
workspaceRoot: "/opt/whalebro",
runtimeEnv: {
CODEWHALE_RUNTIME_TOKEN: "runtime-token",
CODEWHALE_PROVIDER: "arcee"
}
}
);
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/);
});
@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
test("ThreadStore is initialized before bridge startup polls Telegram", async () => {
const source = await fs.readFile(path.join(__dirname, "../src/index.mjs"), "utf8");
const declaration = source.indexOf("class ThreadStore");
const startupUse = source.indexOf("await ThreadStore.open");
const pollCall = source.indexOf("await pollTelegram()");
const reattachCall = source.indexOf("reattachActiveTurns().catch");
assert.notEqual(declaration, -1);
assert.notEqual(startupUse, -1);
assert.notEqual(pollCall, -1);
assert.notEqual(reattachCall, -1);
assert.ok(declaration < startupUse);
assert.ok(startupUse < reattachCall);
assert.ok(reattachCall < pollCall);
});
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "codewhale",
"version": "0.8.52",
"codewhaleBinaryVersion": "0.8.52",
"version": "0.8.53",
"codewhaleBinaryVersion": "0.8.53",
"description": "Install and run CodeWhale, the agentic terminal for open-source and open-weight coding models, from GitHub release artifacts.",
"author": "Hmbown",
"license": "MIT",
+46 -43
View File
@@ -6,15 +6,15 @@ if [[ "${EUID}" -ne 0 ]]; then
exit 1
fi
DEEPSEEK_USER="${DEEPSEEK_USER:-codewhale}"
DEEPSEEK_ROOT="${DEEPSEEK_ROOT:-/opt/codewhale}"
CODEWHALE_USER="${CODEWHALE_USER:-${DEEPSEEK_USER:-codewhale}}"
CODEWHALE_ROOT="${CODEWHALE_ROOT:-${DEEPSEEK_ROOT:-/opt/codewhale}}"
WHALEBRO_ROOT="${WHALEBRO_ROOT:-/opt/whalebro}"
REPO_URL="${DEEPSEEK_REPO_URL:-https://github.com/Hmbown/CodeWhale.git}"
REPO_URL="${CODEWHALE_REPO_URL:-${DEEPSEEK_REPO_URL:-https://github.com/Hmbown/CodeWhale.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}}"
REPO_BRANCH="${CODEWHALE_REPO_BRANCH:-${DEEPSEEK_REPO_BRANCH:-${SOURCE_BRANCH:-main}}}"
apt-get update
apt-get install -y \
@@ -35,22 +35,24 @@ apt-get install -y \
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
echo "Node.js 18+ is required for the phone bridges; 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}"
if ! id -u "${CODEWHALE_USER}" >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash "${CODEWHALE_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/codewhale-feishu-bridge
install -d -o "${CODEWHALE_USER}" -g "${CODEWHALE_USER}" "${CODEWHALE_ROOT}"
install -d -o "${CODEWHALE_USER}" -g "${CODEWHALE_USER}" "${CODEWHALE_ROOT}/bridge"
install -d -o "${CODEWHALE_USER}" -g "${CODEWHALE_USER}" "${CODEWHALE_ROOT}/telegram-bridge"
install -d -o "${CODEWHALE_USER}" -g "${CODEWHALE_USER}" "${WHALEBRO_ROOT}"
install -d -o "${CODEWHALE_USER}" -g "${CODEWHALE_USER}" "${WHALEBRO_ROOT}/worktrees"
install -d -m 0750 -o root -g "${CODEWHALE_USER}" /etc/codewhale
install -d -m 0700 -o "${CODEWHALE_USER}" -g "${CODEWHALE_USER}" /var/lib/codewhale-feishu-bridge
install -d -m 0700 -o "${CODEWHALE_USER}" -g "${CODEWHALE_USER}" /var/lib/codewhale-telegram-bridge
if [[ ! -d "${WHALEBRO_ROOT}/codewhale/.git" ]]; then
sudo -u "${DEEPSEEK_USER}" git clone --branch "${REPO_BRANCH}" "${REPO_URL}" "${WHALEBRO_ROOT}/codewhale"
sudo -u "${CODEWHALE_USER}" git clone --branch "${REPO_BRANCH}" "${REPO_URL}" "${WHALEBRO_ROOT}/codewhale"
fi
for repo_spec in ${WHALEBRO_EXTRA_REPOS}; do
@@ -61,48 +63,49 @@ for repo_spec in ${WHALEBRO_EXTRA_REPOS}; do
continue
fi
if [[ ! -d "${WHALEBRO_ROOT}/${repo_name}/.git" ]]; then
sudo -u "${DEEPSEEK_USER}" git clone "${repo_url}" "${WHALEBRO_ROOT}/${repo_name}" || {
sudo -u "${CODEWHALE_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 /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
if [[ ! -f /etc/codewhale/runtime.env ]]; then
cat >/etc/codewhale/runtime.env <<'EOF'
CODEWHALE_RUNTIME_TOKEN=replace-with-long-random-token
CODEWHALE_RUNTIME_PORT=7878
CODEWHALE_RUNTIME_WORKERS=2
CODEWHALE_PROVIDER=deepseek
DEEPSEEK_API_KEY=replace-with-provider-key
RUST_LOG=info
EOF
chown root:"${DEEPSEEK_USER}" /etc/deepseek/runtime.env
chmod 0640 /etc/deepseek/runtime.env
chown root:"${CODEWHALE_USER}" /etc/codewhale/runtime.env
chmod 0640 /etc/codewhale/runtime.env
fi
if [[ ! -f /etc/deepseek/feishu-bridge.env ]]; then
cat >/etc/deepseek/feishu-bridge.env <<'EOF'
if [[ ! -f /etc/codewhale/feishu-bridge.env ]]; then
cat >/etc/codewhale/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
CODEWHALE_RUNTIME_URL=http://127.0.0.1:7878
CODEWHALE_RUNTIME_TOKEN=replace-with-same-token-as-runtime-env
CODEWHALE_WORKSPACE=/opt/whalebro
CODEWHALE_MODEL=auto
CODEWHALE_MODE=agent
CODEWHALE_ALLOW_SHELL=true
CODEWHALE_TRUST_MODE=false
CODEWHALE_AUTO_APPROVE=false
CODEWHALE_CHAT_ALLOWLIST=
CODEWHALE_ALLOW_UNLISTED=false
FEISHU_THREAD_MAP_PATH=/var/lib/codewhale-feishu-bridge/thread-map.json
FEISHU_ALLOW_GROUPS=false
FEISHU_REQUIRE_PREFIX_IN_GROUP=true
FEISHU_GROUP_PREFIX=/ds
FEISHU_GROUP_PREFIX=/cw
FEISHU_MAX_REPLY_CHARS=3500
DEEPSEEK_TURN_TIMEOUT_MS=900000
CODEWHALE_TURN_TIMEOUT_MS=900000
EOF
chown root:"${DEEPSEEK_USER}" /etc/deepseek/feishu-bridge.env
chmod 0640 /etc/deepseek/feishu-bridge.env
chown root:"${CODEWHALE_USER}" /etc/codewhale/feishu-bridge.env
chmod 0640 /etc/codewhale/feishu-bridge.env
fi
ufw allow OpenSSH
@@ -113,14 +116,14 @@ cat <<EOF
Base server setup complete.
Next:
1. Install Rust 1.88+ for ${DEEPSEEK_USER}; rustup is the usual path.
1. Install Rust 1.88+ for ${CODEWHALE_USER}; rustup is the usual path.
2. Build/install both binaries:
sudo -iu ${DEEPSEEK_USER}
sudo -iu ${CODEWHALE_USER}
cd ${WHALEBRO_ROOT}/codewhale
cargo install --path crates/cli --locked --force
cargo install --path crates/tui --locked --force
3. Copy integrations/feishu-bridge to ${DEEPSEEK_ROOT}/bridge and run npm install.
4. Edit /etc/deepseek/runtime.env and /etc/deepseek/feishu-bridge.env.
3. Copy integrations/feishu-bridge or integrations/telegram-bridge to ${CODEWHALE_ROOT} and run npm install.
4. Edit /etc/codewhale/runtime.env and the selected bridge env file.
5. Install systemd units with scripts/tencent-lighthouse/install-services.sh.
6. After the env files are edited and services are started, run:
sudo bash scripts/tencent-lighthouse/doctor.sh
+92 -32
View File
@@ -1,13 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
DEEPSEEK_USER="${DEEPSEEK_USER:-codewhale}"
DEEPSEEK_ROOT="${DEEPSEEK_ROOT:-/opt/codewhale}"
CODEWHALE_USER="${CODEWHALE_USER:-${DEEPSEEK_USER:-codewhale}}"
CODEWHALE_ROOT="${CODEWHALE_ROOT:-${DEEPSEEK_ROOT:-/opt/codewhale}}"
WHALEBRO_ROOT="${WHALEBRO_ROOT:-/opt/whalebro}"
RUNTIME_ENV="${RUNTIME_ENV:-/etc/deepseek/runtime.env}"
BRIDGE_ENV="${BRIDGE_ENV:-/etc/deepseek/feishu-bridge.env}"
BRIDGE_DIR="${BRIDGE_DIR:-${DEEPSEEK_ROOT}/bridge}"
if [[ -z "${RUNTIME_ENV:-}" ]]; then
if [[ -f /etc/codewhale/runtime.env || ! -f /etc/deepseek/runtime.env ]]; then
RUNTIME_ENV="/etc/codewhale/runtime.env"
else
RUNTIME_ENV="/etc/deepseek/runtime.env"
fi
fi
REPO_ROOT="${REPO_ROOT:-${WHALEBRO_ROOT}/codewhale}"
BRIDGE_KIND="${CODEWHALE_BRIDGE:-${DEEPSEEK_BRIDGE:-feishu}}"
case "${BRIDGE_KIND}" in
feishu|lark)
if [[ -z "${BRIDGE_ENV:-}" ]]; then
if [[ -f /etc/codewhale/feishu-bridge.env || ! -f /etc/deepseek/feishu-bridge.env ]]; then
BRIDGE_ENV="/etc/codewhale/feishu-bridge.env"
else
BRIDGE_ENV="/etc/deepseek/feishu-bridge.env"
fi
fi
BRIDGE_DIR="${BRIDGE_DIR:-${CODEWHALE_ROOT}/bridge}"
BRIDGE_UNIT="${BRIDGE_UNIT:-codewhale-feishu-bridge}"
BRIDGE_PACKAGE="${BRIDGE_PACKAGE:-integrations/feishu-bridge}"
;;
telegram)
if [[ -z "${BRIDGE_ENV:-}" ]]; then
if [[ -f /etc/codewhale/telegram-bridge.env || ! -f /etc/deepseek/telegram-bridge.env ]]; then
BRIDGE_ENV="/etc/codewhale/telegram-bridge.env"
else
BRIDGE_ENV="/etc/deepseek/telegram-bridge.env"
fi
fi
BRIDGE_DIR="${BRIDGE_DIR:-${CODEWHALE_ROOT}/telegram-bridge}"
BRIDGE_UNIT="${BRIDGE_UNIT:-codewhale-telegram-bridge}"
BRIDGE_PACKAGE="${BRIDGE_PACKAGE:-integrations/telegram-bridge}"
;;
*)
echo "Unknown bridge '${BRIDGE_KIND}'. Use CODEWHALE_BRIDGE=feishu or CODEWHALE_BRIDGE=telegram." >&2
exit 1
;;
esac
failures=0
warnings=0
@@ -44,6 +80,20 @@ env_value() {
|| true
}
env_value_any() {
local file="$1"
shift
local value
for key in "$@"; do
value="$(env_value "${file}" "${key}")"
if [[ -n "${value}" ]]; then
printf '%s\n' "${value}"
return 0
fi
done
return 0
}
is_placeholder() {
local value
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')"
@@ -72,7 +122,7 @@ check_commands() {
check_node() {
section "Node"
if ! have_command node; then
fail "node is required for the Feishu bridge"
fail "node is required for the phone bridge"
return
fi
local major
@@ -98,7 +148,7 @@ check_workspace() {
check_binaries() {
section "CodeWhale binaries"
local cargo_bin="/home/${DEEPSEEK_USER}/.cargo/bin"
local cargo_bin="/home/${CODEWHALE_USER}/.cargo/bin"
local codewhale="${cargo_bin}/codewhale"
local tui="${cargo_bin}/codewhale-tui"
if [[ -x "${codewhale}" ]]; then
@@ -138,22 +188,28 @@ check_env() {
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)"
local runtime_token bridge_token workspace domain allow_groups allow_unlisted provider
runtime_token="$(env_value_any "${RUNTIME_ENV}" CODEWHALE_RUNTIME_TOKEN DEEPSEEK_RUNTIME_TOKEN)"
bridge_token="$(env_value_any "${BRIDGE_ENV}" CODEWHALE_RUNTIME_TOKEN DEEPSEEK_RUNTIME_TOKEN)"
workspace="$(env_value_any "${BRIDGE_ENV}" CODEWHALE_WORKSPACE DEEPSEEK_WORKSPACE)"
provider="$(env_value_any "${RUNTIME_ENV}" CODEWHALE_PROVIDER DEEPSEEK_PROVIDER)"
if [[ "${BRIDGE_KIND}" == "telegram" ]]; then
allow_groups="$(env_value "${BRIDGE_ENV}" TELEGRAM_ALLOW_GROUPS)"
allow_unlisted="$(env_value_any "${BRIDGE_ENV}" TELEGRAM_ALLOW_UNLISTED CODEWHALE_ALLOW_UNLISTED DEEPSEEK_ALLOW_UNLISTED)"
else
domain="$(env_value "${BRIDGE_ENV}" FEISHU_DOMAIN)"
allow_groups="$(env_value "${BRIDGE_ENV}" FEISHU_ALLOW_GROUPS)"
allow_unlisted="$(env_value_any "${BRIDGE_ENV}" CODEWHALE_ALLOW_UNLISTED DEEPSEEK_ALLOW_UNLISTED)"
fi
if is_placeholder "${runtime_token}"; then
fail "runtime DEEPSEEK_RUNTIME_TOKEN is missing or still a placeholder"
fail "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"
fail "bridge token is missing or still a placeholder"
else
pass "bridge token is set"
fi
@@ -162,19 +218,21 @@ check_env() {
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"
if is_placeholder "${provider}"; then
warn "runtime provider is missing or still a placeholder"
else
pass "DEEPSEEK_API_KEY is set"
pass "runtime provider is ${provider}"
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"
if [[ "${BRIDGE_KIND}" != "telegram" ]]; then
[[ "${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"
fi
[[ "${allow_groups:-false}" == "true" && "${allow_unlisted:-false}" == "true" ]] \
&& fail "group control cannot run with DEEPSEEK_ALLOW_UNLISTED=true" \
&& fail "group control cannot run with allow-unlisted=true" \
|| pass "group/unlisted mode is not openly combined"
}
@@ -182,15 +240,15 @@ 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"
validator="${REPO_ROOT}/${BRIDGE_PACKAGE}/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)
if [[ "${EUID}" -eq 0 ]] && id -u "${CODEWHALE_USER}" >/dev/null 2>&1 && have_command sudo; then
runner=(sudo -u "${CODEWHALE_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"
@@ -205,7 +263,7 @@ check_systemd() {
warn "systemd is not available in this environment"
return
fi
for unit in codewhale-runtime codewhale-feishu-bridge; do
for unit in codewhale-runtime "${BRIDGE_UNIT}"; do
[[ -f "/etc/systemd/system/${unit}.service" ]] \
&& pass "${unit}.service is installed" \
|| fail "${unit}.service is missing"
@@ -222,7 +280,9 @@ 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
if [[ "${BRIDGE_KIND}" == "telegram" ]]; then
pass "Telegram bridge has no required production npm dependencies"
elif [[ -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"
@@ -232,9 +292,9 @@ check_bridge_install() {
check_localhost_health() {
section "Localhost health"
local port token
port="$(env_value "${RUNTIME_ENV}" DEEPSEEK_RUNTIME_PORT)"
port="$(env_value_any "${RUNTIME_ENV}" CODEWHALE_RUNTIME_PORT DEEPSEEK_RUNTIME_PORT)"
port="${port:-7878}"
token="$(env_value "${BRIDGE_ENV}" DEEPSEEK_RUNTIME_TOKEN)"
token="$(env_value_any "${BRIDGE_ENV}" CODEWHALE_RUNTIME_TOKEN DEEPSEEK_RUNTIME_TOKEN)"
if have_command ss; then
local listeners
@@ -287,7 +347,7 @@ check_localhost_health() {
}
main() {
printf 'Tencent Lighthouse DeepSeek doctor\n'
printf 'Tencent Lighthouse CodeWhale doctor (%s bridge)\n' "${BRIDGE_KIND}"
check_commands
check_node
check_workspace
+59 -18
View File
@@ -7,39 +7,80 @@ if [[ "${EUID}" -ne 0 ]]; then
fi
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DEEPSEEK_USER="${DEEPSEEK_USER:-codewhale}"
DEEPSEEK_ROOT="${DEEPSEEK_ROOT:-/opt/codewhale}"
CODEWHALE_USER="${CODEWHALE_USER:-${DEEPSEEK_USER:-codewhale}}"
CODEWHALE_ROOT="${CODEWHALE_ROOT:-${DEEPSEEK_ROOT:-/opt/codewhale}}"
BRIDGE_KIND="${CODEWHALE_BRIDGE:-${DEEPSEEK_BRIDGE:-feishu}}"
install -d -o "${DEEPSEEK_USER}" -g "${DEEPSEEK_USER}" "${DEEPSEEK_ROOT}/bridge"
case "${BRIDGE_KIND}" in
feishu|lark)
BRIDGE_SRC="integrations/feishu-bridge"
BRIDGE_DST="${CODEWHALE_ROOT}/bridge"
BRIDGE_UNIT="codewhale-feishu-bridge.service"
BRIDGE_ENV="/etc/codewhale/feishu-bridge.env"
BRIDGE_ENV_EXAMPLE="deploy/tencent-lighthouse/examples/feishu-bridge.env.example"
BRIDGE_STATE_DIR="/var/lib/codewhale-feishu-bridge"
VALIDATOR="integrations/feishu-bridge/scripts/validate-config.mjs"
;;
telegram)
BRIDGE_SRC="integrations/telegram-bridge"
BRIDGE_DST="${CODEWHALE_ROOT}/telegram-bridge"
BRIDGE_UNIT="codewhale-telegram-bridge.service"
BRIDGE_ENV="/etc/codewhale/telegram-bridge.env"
BRIDGE_ENV_EXAMPLE="deploy/tencent-lighthouse/examples/telegram-bridge.env.example"
BRIDGE_STATE_DIR="/var/lib/codewhale-telegram-bridge"
VALIDATOR="integrations/telegram-bridge/scripts/validate-config.mjs"
;;
*)
echo "Unknown bridge '${BRIDGE_KIND}'. Use CODEWHALE_BRIDGE=feishu or CODEWHALE_BRIDGE=telegram." >&2
exit 1
;;
esac
install -d -m 0750 -o root -g "${CODEWHALE_USER}" /etc/codewhale
install -d -m 0700 -o "${CODEWHALE_USER}" -g "${CODEWHALE_USER}" "${BRIDGE_STATE_DIR}"
install -d -o "${CODEWHALE_USER}" -g "${CODEWHALE_USER}" "${BRIDGE_DST}"
if [[ ! -f /etc/codewhale/runtime.env && -f "${REPO_ROOT}/deploy/tencent-lighthouse/examples/runtime.env.example" ]]; then
install -m 0640 -o root -g "${CODEWHALE_USER}" \
"${REPO_ROOT}/deploy/tencent-lighthouse/examples/runtime.env.example" \
/etc/codewhale/runtime.env
fi
if [[ ! -f "${BRIDGE_ENV}" && -f "${REPO_ROOT}/${BRIDGE_ENV_EXAMPLE}" ]]; then
install -m 0640 -o root -g "${CODEWHALE_USER}" \
"${REPO_ROOT}/${BRIDGE_ENV_EXAMPLE}" \
"${BRIDGE_ENV}"
fi
rsync -a --delete \
--exclude node_modules \
"${REPO_ROOT}/integrations/feishu-bridge/" \
"${DEEPSEEK_ROOT}/bridge/"
chown -R "${DEEPSEEK_USER}:${DEEPSEEK_USER}" "${DEEPSEEK_ROOT}/bridge"
"${REPO_ROOT}/${BRIDGE_SRC}/" \
"${BRIDGE_DST}/"
chown -R "${CODEWHALE_USER}:${CODEWHALE_USER}" "${BRIDGE_DST}"
if [[ -f "${DEEPSEEK_ROOT}/bridge/package-lock.json" ]]; then
sudo -u "${DEEPSEEK_USER}" npm --prefix "${DEEPSEEK_ROOT}/bridge" ci --omit=dev
if [[ -f "${BRIDGE_DST}/package-lock.json" ]]; then
sudo -u "${CODEWHALE_USER}" npm --prefix "${BRIDGE_DST}" ci --omit=dev
else
sudo -u "${DEEPSEEK_USER}" npm --prefix "${DEEPSEEK_ROOT}/bridge" install --omit=dev
sudo -u "${CODEWHALE_USER}" npm --prefix "${BRIDGE_DST}" install --omit=dev
fi
install -m 0644 "${REPO_ROOT}/deploy/tencent-lighthouse/systemd/codewhale-runtime.service" /etc/systemd/system/codewhale-runtime.service
install -m 0644 "${REPO_ROOT}/deploy/tencent-lighthouse/systemd/codewhale-feishu-bridge.service" /etc/systemd/system/codewhale-feishu-bridge.service
install -m 0644 "${REPO_ROOT}/deploy/tencent-lighthouse/systemd/${BRIDGE_UNIT}" "/etc/systemd/system/${BRIDGE_UNIT}"
systemctl daemon-reload
systemctl enable codewhale-runtime codewhale-feishu-bridge
systemctl enable codewhale-runtime "${BRIDGE_UNIT}"
cat <<'EOF'
Services installed but not started.
Before starting, verify:
/etc/deepseek/runtime.env
/etc/deepseek/feishu-bridge.env
sudo -u codewhale node /opt/codewhale/bridge/scripts/validate-config.mjs --env /etc/deepseek/feishu-bridge.env --runtime-env /etc/deepseek/runtime.env --workspace-root /opt/whalebro --check-filesystem
/etc/codewhale/runtime.env
EOF
cat <<EOF
${BRIDGE_ENV}
sudo -u ${CODEWHALE_USER} node ${REPO_ROOT}/${VALIDATOR} --env ${BRIDGE_ENV} --runtime-env /etc/codewhale/runtime.env --workspace-root /opt/whalebro --check-filesystem
Then run:
sudo systemctl start codewhale-runtime
sudo systemctl start codewhale-feishu-bridge
sudo bash /opt/whalebro/codewhale/scripts/tencent-lighthouse/doctor.sh
sudo journalctl -u codewhale-feishu-bridge -f
sudo systemctl start ${BRIDGE_UNIT}
sudo CODEWHALE_BRIDGE=${BRIDGE_KIND} bash /opt/whalebro/codewhale/scripts/tencent-lighthouse/doctor.sh
sudo journalctl -u ${BRIDGE_UNIT} -f
EOF