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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user