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:
+9
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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/`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
`);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user