diff --git a/packages/telegram-remote/.gitignore b/packages/telegram-remote/.gitignore index f25ba5b..1e766eb 100644 --- a/packages/telegram-remote/.gitignore +++ b/packages/telegram-remote/.gitignore @@ -6,3 +6,6 @@ node_modules/ *state.json .env + +# The plugin ships its built JS (plain-ESM, no build step); keep lib/ in the package. +!lib/ diff --git a/packages/telegram-remote/README.md b/packages/telegram-remote/README.md index 8a562dc..19a73fb 100644 --- a/packages/telegram-remote/README.md +++ b/packages/telegram-remote/README.md @@ -9,6 +9,7 @@ Turn any Telegram chat into a pocket terminal for your harness: chat with your A ## ✨ Features - πŸ’¬ **Chat from Telegram** β€” send a message, get a live-streamed structured reply (thinking, tools, final answer) edited in place +- πŸŽ™οΈ **Voice & audio** β€” send a voice message or audio file; it is transcribed through your home-lab Whisper server (default `large-v2` on CUDA GPU) and the text is sent to your AI - πŸŽ› **Full harness control** β€” 49 commands: chats, models, sessions, subagents, goals, jobs, files, PowerShell, exports, presets, skills, settings, credentials, permissions, and more - πŸ“‘ **Live state** β€” status, running turns, queued messages (steer / edit / remove), background jobs - πŸ” **Permission-aware** β€” sandbox read/write/full control per chat, approval buttons for risky tools @@ -77,6 +78,12 @@ Turn any Telegram chat into a pocket terminal for your harness: chat with your A | `allowEval` | `true` | Enable `/eval` (runs JS in the harness) | | `notifyOnStartup` | `true` | Send a "bot online" message on boot | | `stateFile` / `logFile` | `~/.dsh/...` | Override runtime state / log paths | +| `whisperBaseUrl` | `"http://192.168.31.159:8082"` | Home-lab Whishper server base URL | +| `whisperModel` | `"large-v2"` | Whisper model used for transcription | +| `whisperDevice` | `"cuda"` | Whishper device (`cuda` or `cpu`; GPU is `cuda`) | +| `whisperLanguage` | `"auto"` | Language hint (`"auto"` = auto-detect; e.g. `ru`, `en`) | +| `whisperTimeoutMs` | `120000` | Max wait for a transcription to finish | +| `whisperMaxBytes` | `20971520` | Max accepted audio size in bytes (20 MiB) | ## πŸ’¬ Usage @@ -116,7 +123,7 @@ Tap the **/start** keyboard or type `/help` for the full command list. Key comma - The bot can control your computer β€” **only add users you trust** (`ownerChatId` / `allowedUserIds`). - Every request is checked against the per-chat `/permission` mode; risky tools request approval. - `/eval` and `/cmd` are powerful β€” consider `allowEval: false` unless you need them. -- No telemetry, no external services: the bot talks to Telegram and your harness only. +- No telemetry: the bot talks to Telegram, your harness, and (for voice/audio) your home-lab Whishper server only. ## πŸ”„ Hot reload (development) diff --git a/packages/telegram-remote/lib/bot.js b/packages/telegram-remote/lib/bot.js new file mode 100644 index 0000000..2b0ba51 --- /dev/null +++ b/packages/telegram-remote/lib/bot.js @@ -0,0 +1,303 @@ +/** + * Zero-dependency Telegram Bot API client (long polling) for dsh-telegram-remote. + * Uses global fetch (Node >= 22). No external services, no webhooks: the + * harness polls api.telegram.org directly, so it works behind NAT / no + * inbound ports. + */ + +const API_BASE = "https://api.telegram.org"; +const MAX_MESSAGE_LENGTH = 4096; + +/** Escape text for Telegram HTML parse_mode. */ +export function htmlEscape(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +/** Plain-text value with optional HTML tagging; safe when embedded in HTML mode. */ +export function esc(value) { + return htmlEscape(value ?? ""); +} + +export class TelegramBot { + token; + log; + offset = 0; + stopped = false; + loop = null; + pollAbort = null; + lastPollAt = 0; + me = null; + seen = new Set(); + + constructor(token, log = () => {}) { + if (!token || typeof token !== "string" || !token.includes(":")) { + throw new Error(`telegram-remote: invalid bot token (${typeof token})`); + } + this.token = token; + this.log = log; + // Called whenever the poll cursor advances, so callers can persist it. + this.onOffset = null; + } + + async call(method, params = {}, { timeoutMs = 90_000, signal } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + // Chain an external signal (e.g. the poll abort) so stop() can kill an + // in-flight getUpdates immediately instead of waiting for its timeout. + // The listener is removed on completion: the poll loop reuses ONE + // controller per request, so without cleanup listeners would accumulate. + const onAbort = () => controller.abort(); + if (signal) { + if (signal.aborted) controller.abort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + try { + const res = await fetch(`${API_BASE}/bot${this.token}/${method}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(params), + signal: controller.signal, + }); + if (!res.ok) { + const text = (await res.text()).slice(0, 500); + throw new Error(`telegram ${method}: HTTP ${res.status} ${text}`); + } + const data = await res.json(); + if (!data.ok) { + const err = new Error(`telegram ${method}: ${data.description ?? "unknown error"}`); + err.code = data.error_code; + throw err; + } + return data.result; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } + } + + /** Verify the token and cache bot identity. */ + async getMe() { + this.me = await this.call("getMe", {}); + return this.me; + } + + /** Set the slash-command menu shown when the user types "/". */ + async setMyCommands(commands) { + await this.call("setMyCommands", { commands }); + } + + /** + * Send a text message, splitting into Telegram-sized chunks. + * @returns {Promise} message ids + */ + async send(chatId, text, opts = {}) { + const { parseMode = "HTML", replyMarkup, replyToMessageId } = opts; + const chunks = splitMessage(String(text ?? "")); + const ids = []; + // Plain text needs no HTML mode: skip it to save a Telegram parse pass. + const useHtml = parseMode === "HTML" && /[<&]/.test(String(text ?? "")); + const base = { + chat_id: chatId, + disable_web_page_preview: true, + ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + ...(replyToMessageId ? { reply_to_message_id: replyToMessageId } : {}), + }; + for (const chunk of chunks) { + const payload = { ...base, text: chunk, ...(useHtml ? { parse_mode: "HTML" } : {}) }; + let result; + try { + result = await this.call("sendMessage", payload); + } catch (error) { + // Telegram returns HTTP 400 for bad HTML (the .code field isn't set + // on the HTTP path), so match on the message text. Fall back to plain + // text rather than losing the message. + if (/400|parse entities|can't parse/i.test(String(error?.message ?? ""))) { + result = await this.call("sendMessage", { ...base, text: chunk }); + } else { + throw error; + } + } + ids.push(result.message_id); + } + return ids; + } + + /** Edit one of our own messages (used for live streaming of replies). */ + async editMessageText(chatId, messageId, text) { + try { + return await this.call("editMessageText", { + chat_id: chatId, + message_id: messageId, + text, + parse_mode: "HTML", + disable_web_page_preview: true, + }); + } catch (error) { + // Never lose a live edit to a parse error: retry as plain text. + if (/400|parse entities|can't parse/i.test(String(error?.message ?? ""))) { + return this.call("editMessageText", { + chat_id: chatId, + message_id: messageId, + text, + disable_web_page_preview: true, + }); + } + throw error; + } + } + + /** Delete one of our own messages. */ + async deleteMessage(chatId, messageId) { + try { + await this.call("deleteMessage", { chat_id: chatId, message_id: messageId }); + } catch {} + } + + /** Resolve a file id to a downloadable path. */ + async getFile(fileId) { + return this.call("getFile", { file_id: fileId }); + } + + /** Download a bot file (file_path from getFile) into a Buffer. */ + async downloadFile(filePath) { + const res = await fetch(`${API_BASE}/file/bot${this.token}/${filePath}`); + if (!res.ok) throw new Error("telegram downloadFile: HTTP " + res.status); + return Buffer.from(await res.arrayBuffer()); + } + + /** Send a binary document (e.g. a session export zip). */ + async sendDocument(chatId, buffer, filename, caption) { + const form = new FormData(); + form.append("chat_id", String(chatId)); + form.append("document", new Blob([buffer]), filename); + if (caption) form.append("caption", caption); + const res = await fetch(`${API_BASE}/bot${this.token}/sendDocument`, { method: "POST", body: form }); + const data = await res.json(); + if (!data.ok) throw new Error("telegram sendDocument: " + (data.description ?? "HTTP " + res.status)); + return data.result; + } + + /** Send a photo, falling back to a document past Telegram's 10 MB sendPhoto limit (sendDocument accepts up to 50 MB); throws above 50 MB. */ + async sendPhoto(chatId, buffer, { filename = "image.png", caption } = {}) { + if (buffer.length > 50_000_000) throw new Error("telegram sendPhoto: image exceeds 50 MB"); + if (buffer.length > 10_000_000) return this.sendDocument(chatId, buffer, filename, caption); + const form = new FormData(); + form.append("chat_id", String(chatId)); + form.append("photo", new Blob([buffer]), filename); + if (caption) form.append("caption", caption); + const res = await fetch(`${API_BASE}/bot${this.token}/sendPhoto`, { method: "POST", body: form }); + const data = await res.json(); + if (!data.ok) throw new Error("telegram sendPhoto: " + (data.description ?? "HTTP " + res.status)); + return data.result; + } + + async answerCallbackQuery(callbackQueryId, text) { + try { + await this.call("answerCallbackQuery", { + callback_query_id: callbackQueryId, + ...(text ? { text: String(text).slice(0, 200) } : {}), + }); + } catch (error) { + this.log(`answerCallbackQuery failed: ${error.message}`); + } + } + + /** + * Start the long-polling loop. Callback receives { type: "message"|"callback_query", update }. + */ + start(onUpdate) { + // A stopped bot stays stopped forever: HMR reloads stop the previous + // poller, and any late async callbacks (getMe / setupDispatch) must NOT + // revive it β€” two getUpdates loops would fight for the token (409s). + if (this.loop || this.stopped) return; + this.loop = this.#run(onUpdate); + return this.loop; + } + + async #run(onUpdate) { + let consecutiveErrors = 0; + while (!this.stopped) { + const controller = new AbortController(); + this.pollAbort = controller; + try { + const timeout = 50; + // Reuse the loop controller directly as the abort signal β€” no extra + // listener chaining, and stop() aborts the in-flight request at once. + const result = await this.call( + "getUpdates", + { + timeout, + offset: this.offset, + allowed_updates: ["message", "callback_query"], + }, + { timeoutMs: (timeout + 15) * 1000, signal: controller.signal }, + ); + consecutiveErrors = 0; + this.lastPollAt = Date.now(); + for (const update of result ?? []) { + if (typeof update.update_id !== "number") continue; + const next = update.update_id + 1; + if (next > this.offset) { + this.offset = next; + try { this.onOffset?.(this.offset); } catch {} + } + if (this.seen.has(update.update_id)) continue; + this.seen.add(update.update_id); + if (this.seen.size > 10_000) { + const keep = [...this.seen].slice(-5_000); + this.seen = new Set(keep); + } + try { + await onUpdate(update); + } catch (error) { + this.log(`update ${update.update_id} handler failed: ${error.stack ?? error.message}`); + } + } + } catch (error) { + if (this.stopped) break; + consecutiveErrors += 1; + const code = error?.code; + if (code === 401) { + this.log(`telegram-remote: FATAL β€” token rejected (401 Unauthorized)`); + await sleep(30_000); + } else if (code === 409) { + this.log(`telegram-remote: getUpdates conflict β€” another poller is active for this bot`); + await sleep(10_000); + } else { + this.log(`telegram-remote: getUpdates error: ${error.message}`); + await sleep(Math.min(2_000 * consecutiveErrors, 30_000)); + } + } finally { + this.pollAbort = null; + } + } + this.loop = null; + } + + stop() { + this.stopped = true; + this.pollAbort?.abort(); + } +} + +/** Split long text into Telegram-safe chunks on line boundaries. */ +export function splitMessage(text) { + if (text.length <= MAX_MESSAGE_LENGTH) return [text]; + const chunks = []; + let rest = text; + while (rest.length > MAX_MESSAGE_LENGTH) { + let cut = rest.lastIndexOf("\n", MAX_MESSAGE_LENGTH); + if (cut <= 0) cut = MAX_MESSAGE_LENGTH; + chunks.push(rest.slice(0, cut)); + rest = rest.slice(cut); + } + if (rest.length > 0) chunks.push(rest); + return chunks; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/telegram-remote/lib/commands.js b/packages/telegram-remote/lib/commands.js new file mode 100644 index 0000000..99a53a1 --- /dev/null +++ b/packages/telegram-remote/lib/commands.js @@ -0,0 +1,1255 @@ +/** + * Command handlers for dsh-telegram-remote. + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, isAbsolute, resolve } from "node:path"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { esc } from "./bot.js"; +import { dshHome, invoke, callApi, pretty, truncate, fmtAge, shortId, contentText } from "./util.js"; +import { cmdRename, cmdFork, cmdSearch, cmdExport, cmdWorkspaces, cmdWs, cmdMkdir, cmdArchive, cmdInterrupt, cmdAgentLog, cmdPresets, cmdPreset, cmdSkills, cmdPlugins, cmdSettings, cmdSetting, cmdCreds, cmdPermission } from "./features.js"; + +export const KEYBOARD = { + inline_keyboard: [ + [ + { text: "πŸ’¬ New chat", callback_data: "/new" }, + { text: "πŸ“‹ My chats", callback_data: "/chats" }, + ], + [ + { text: "🧠 Model", callback_data: "/models" }, + { text: "πŸ“Š Status", callback_data: "/status" }, + ], + [ + { text: "❓ Help", callback_data: "/help" }, + { text: "⏹ Stop AI", callback_data: "/stop" }, + { text: "πŸ”” Notify", callback_data: "/notify" }, + ], + ], +}; + +export const BUSY_KEYBOARD = { + inline_keyboard: [ + [ + { text: "▢️ Send now", callback_data: "/steernow" }, + { text: "⏳ Queue it", callback_data: "/queuemsg" }, + ], + ], +}; + +/** Telegram native command menu β€” EVERY command the bot supports. */ +export const COMMANDS_MENU = [ + { command: "start", description: "Welcome & quick start" }, + { command: "help", description: "Full guide to everything" }, + { command: "new", description: "Start a fresh chat" }, + { command: "chats", description: "List chats β€” /chats pages, reply a number to open" }, + { command: "sessions", description: "List chats (same as /chats)" }, + { command: "open", description: "Open a chat by id or number" }, + { command: "msg", description: "Send a message to the chat" }, + { command: "log", description: "Recent messages of the chat" }, + { command: "stop", description: "Stop the AI" }, + { command: "queue", description: "Queued messages β€” steer/edit/remove" }, + { command: "steer", description: "Send into the running turn now" }, + { command: "edit", description: "Rewrite a queued message" }, + { command: "remove", description: "Remove a queued message" }, + { command: "status", description: "What's happening right now" }, + { command: "model", description: "This chat's model" }, + { command: "models", description: "Browse available models" }, + { command: "rename", description: "Rename a chat" }, + { command: "fork", description: "Copy a chat to experiment" }, + { command: "search", description: "Search past conversations" }, + { command: "export", description: "Download a chat as a file" }, + { command: "archive", description: "Tuck a chat away" }, + { command: "workspaces", description: "Folders your chats live in" }, + { command: "ws", description: "Workspace: new|rename|delete" }, + { command: "agents", description: "Subagent children of this chat" }, + { command: "send", description: "Message a subagent" }, + { command: "interrupt", description: "Stop a subagent" }, + { command: "agentlog", description: "A subagent's recent messages" }, + { command: "cmd", description: "Run a PowerShell command" }, + { command: "fs", description: "Files: ls|read|write|rm|stat" }, + { command: "mkdir", description: "Create a folder" }, + { command: "jobs", description: "Background tasks" }, + { command: "kill", description: "Kill a background task" }, + { command: "goal", description: "Goal: get|create|pause|clear|..." }, + { command: "permission", description: "Chat access: read|write|full" }, + { command: "presets", description: "Agent presets" }, + { command: "preset", description: "Switch this chat's preset" }, + { command: "skills", description: "Available skills" }, + { command: "plugins", description: "Loaded plugins" }, + { command: "settings", description: "Settings sections" }, + { command: "setting", description: "Change a setting (JSON)" }, + { command: "creds", description: "API keys: describe|set|unset" }, + { command: "raw", description: "Call any harness endpoint" }, + { command: "api", description: "List all harness endpoints" }, + { command: "config", description: "Show config files" }, + { command: "eval", description: "Run JS in the harness" }, + { command: "notify", description: "Notifications on/off/all" }, + { command: "reboot", description: "Restart the harness (same port)" }, + { command: "shutdown", description: "Stop the harness" }, + { command: "whoami", description: "Your telegram id" }, +]; + +export const HELP = "" + + "πŸ“– How to use me\n\n" + + "Just type a message and I'll send it to your AI β€” the reply streams here live.\n" + + "─────── β‹†β‹…β˜†β‹…β‹† ───────\n\n" + + "πŸ—¨ Chat\n" + + "πŸ’¬ /new β€” start a fresh chat\n" + + "πŸ“‹ /chats β€” see your chats (/chats 2 pages; reply a number to open)\n" + + "🧠 /model β€” switch the AI's model (try /model max)\n" + + "πŸ“Š /status β€” what's happening right now\n" + + "⏹ /stop β€” stop the AI if it's working\n" + + "πŸ“₯ /queue β€” messages waiting (edit / steer / remove them)\n" + + "πŸ”” /notify on|off β€” reply notifications on/off\n\n" + + "πŸ›  Control\n" + + "/cmd <powershell> β€” run a command on the computer\n" + + "/fs read|ls <path> β€” look at files\n" + + "/jobs / /kill <job> β€” background tasks\n" + + "/goal β€” set & track a long-running objective\n" + + "πŸ” /permission β€” chat access: read | write | full\n\n" + + "🧩 Explore\n" + + "/agents β€” subagents Β· /presets β€” agent presets\n" + + "/skills β€” skills Β· /plugins β€” loaded plugins\n" + + "/export β€” download a chat Β· /search β€” find things\n\n" + + "─────── β‹†β‹…β˜†β‹…β‹† ───────\n" + + "Everything runs on your own computer. Only you can talk to this bot."; + +export const WELCOME = "" + + "πŸ‘‹ Welcome to your DeepSeek Harness πŸš€\n" + + "your AI, in your pocket\n\n" + + "─────── β‹†β‹…β˜†β‹…β‹† ───────\n\n" + + "πŸ’¬ Chat β€” just type a message and I'll pass it to your AI\n" + + "πŸ› οΈ Do β€” write code, run commands, manage files\n" + + "πŸ“Š Watch β€” live streams, status, chats, tasks\n\n" + + "─────── β‹†β‹…β˜†β‹…β‹† ───────\n\n" + + "Try typing hello, or tap a button below πŸ‘‡"; + +export const COMMAND_TABLE = { + start: cmdStart, + welcome: cmdStart, + help: cmdHelp, + chats: cmdSessions, + chat: cmdSessions, + status: cmdStatus, + models: cmdModels, + model: cmdModel, + sessions: cmdSessions, + list: cmdSessions, + open: cmdOpen, + use: cmdOpen, + new: cmdNew, + msg: cmdMsg, + q: cmdMsg, + steer: cmdSteer, + steernow: cmdSteerNow, + queuemsg: cmdQueueMsg, + queue: cmdQueue, + edit: cmdEdit, + remove: cmdRemove, + stop: cmdStop, + jobs: cmdJobs, + kill: cmdKill, + goal: cmdGoal, + agents: cmdAgents, + send: cmdSubagentSend, + cmd: cmdShell, + sh: cmdShell, + fs: cmdFs, + log: cmdLog, + raw: cmdRaw, + api: cmdApi, + eval: cmdEval, + config: cmdConfig, + notify: cmdNotify, + reboot: cmdReboot, + shutdown: cmdShutdown, + whoami: cmdWhoami, + // full-harness feature commands (features.js) + rename: cmdRename, + fork: cmdFork, + search: cmdSearch, + export: cmdExport, + workspaces: cmdWorkspaces, + ws: cmdWs, + mkdir: cmdMkdir, + archive: cmdArchive, + interrupt: cmdInterrupt, + agentlog: cmdAgentLog, + presets: cmdPresets, + preset: cmdPreset, + skills: cmdSkills, + plugins: cmdPlugins, + settings: cmdSettings, + setting: cmdSetting, + creds: cmdCreds, + permission: cmdPermission, +}; + +async function cmdHelp(r) { + return HELP; +} + +async function cmdStart(r) { + // New chats: notifications are on by default so replies always arrive. + const state = r.state.chatState(r.chatId); + if (state.notify !== "session" && state.notify !== "all") { + state.notify = "session"; + r.state.saveState(); + } + return WELCOME; +} + +async function cmdWhoami(r) { + return "your telegram user id: " + r.userId + "\nchat id: " + r.chatId + "\nauthorized: " + r.authorized + ""; +} + +export async function collectJobs(ctx) { + const jobs = ctx.get("jobs"); + if (!jobs) return []; + const agents = ctx.get("agents"); + const sessions = ctx.get("sessions"); + const seen = new Map(); + for (const session of sessions.list()) { + const agent = agents.get(session.id); + if (!agent) continue; + try { + for (const job of jobs.list(agent)) seen.set(job.id, job); + } catch {} + } + try { + for (const job of jobs.list(undefined)) seen.set(job.id, job); + } catch {} + return [...seen.values()]; +} + +async function cmdStatus(r) { + const lines = ["πŸ“Š Status\n─────── β‹†β‹…β˜†β‹…β‹† ───────"]; + const gateway = r.ctx.get("typertGateway"); + try { + const host = await callApi(r.ctx, "host", "describe", {}); + lines.push("🟒 Online β€” " + (host.provider ? "model " + esc(host.provider) + "/" + esc(host.model ?? "?") : "ready")); + } catch { + lines.push("🟒 Online"); + } + try { + const sessions = await r.state.listSessions(); + const running = sessions.filter((item) => item.running).length; + lines.push("πŸ’¬ Chats β€” " + sessions.length + " total Β· " + running + " working"); + const active = r.state.chatState(r.chatId).sessionId; + if (active) { + const title = sessions?.find((item) => item.sessionId === active)?.projections?.values?.title; + lines.push(" πŸ“Œ current: " + esc(title ?? shortId(active)) + ""); + } + } catch { + lines.push("πŸ’¬ Chats: n/a"); + } + try { + const jobs = await collectJobs(r.ctx); + const activeJobs = jobs.filter((job) => job.status === "running" || job.status === "stopping"); + lines.push("βš™οΈ Tasks β€” " + jobs.length + " total Β· " + activeJobs.length + " running"); + } catch { + lines.push("βš™οΈ Tasks: n/a"); + } + try { + const goal = await getGoal(r.ctx, r.state, r.chatId); + if (goal) lines.push("🎯 Goal β€” " + esc(truncate(goal.objective, 100)) + " (" + goal.phase + ")"); + } catch {} + lines.push( + "πŸ–₯️ Computer β€” pid " + process.pid + " Β· up " + fmtAge(Date.now() - Math.floor(process.uptime() * 1000)), + "πŸ€– Bot β€” " + esc(r.state.bot.me?.username ?? "?") + " Β· polling " + (r.state.bot.lastPollAt ? fmtAge(r.state.bot.lastPollAt) : "starting"), + ); + lines.push("─────── β‹†β‹…β˜†β‹…β‹† ───────"); + return lines.join("\n"); +} + +async function getCatalog(ctx) { + const result = await callApi(ctx, "llm", "models", {}); + return result?.groups ?? []; +} + +/** Flatten provider groups into { provider, id, name, efforts } rows. */ +function flattenCatalog(catalog) { + const out = []; + for (const group of catalog ?? []) { + const provider = group.id ?? group.group?.id ?? ""; + const models = group.models ?? group.group?.models ?? []; + for (const model of models) { + if (!model?.id) continue; + out.push({ + provider, + id: String(model.id), + name: model.name ?? model.id, + efforts: (model.reasoning?.efforts ?? []).map((e) => e.id), + }); + } + } + return out; +} + +async function currentModelInfo(ctx, state, chatId) { + const sessionId = await state.resolveSessionId(chatId, ""); + let current = {}; + if (sessionId) { + try { + const r = await callApi(ctx, "sessions", "models", { sessionId }); + current = r?.current ?? {}; + } catch {} + } + const def = ctx.get("agentDefaultModel")?.currentSelection?.() ?? {}; + return { sessionId, current, def }; +} + +async function cmdModels(r) { + const catalog = await getCatalog(r.ctx); + const flat = flattenCatalog(catalog); + if (flat.length === 0) return "No models are available right now β€” check your API keys in the web Models page."; + let currentId = ""; + try { + const sessionId = await r.state.resolveSessionId(r.chatId, ""); + if (sessionId) { + const info = await callApi(r.ctx, "sessions", "models", { sessionId }); + currentId = info?.current?.model ?? ""; + } + } catch {} + const cs = r.state.chatState(r.chatId); + cs.lastModels = flat.map((m) => ({ provider: m.provider, model: m.id })); + cs.lastModelsAt = Date.now(); + r.state.saveState(); + const lines = ["🧠 Pick a model", "tap a button, or reply with a number:"]; + const rows = []; + const shown = flat.slice(0, 18); + shown.forEach((m, i) => { + const n = i + 1; + const on = m.id === currentId; + lines.push((on ? "βœ… " : "") + "" + n + ". " + esc(m.id) + "" + (m.efforts.includes("max") ? " ⭐" : "")); + const cmd = "/model " + m.id; + const cb = cmd.length <= 64 ? cmd : "md:" + n; + const label = (on ? "βœ… " : "") + n + ". " + (m.id.length > 26 ? m.id.slice(0, 24) + "…" : m.id); + rows.push([{ text: label, callback_data: cb }]); + }); + if (flat.length > shown.length) lines.push("+ " + (flat.length - shown.length) + " more β€” type /model <id>"); + rows.push([{ text: "⭐ Deepest thinking (max)", callback_data: "/model max" }]); + return { text: truncate(lines.join("\n"), 3500), keyboard: { inline_keyboard: rows } }; +} + +async function cmdModel(r) { + const args = r.args; + const sessionId = await r.state.resolveSessionId(r.chatId, ""); + if (!sessionId && args.length === 0) { + const info = await currentModelInfo(r.ctx, r.state, r.chatId); + const lines = ["This chat: none open yet β€” tap πŸ’¬ New chat"]; + if (info.def?.provider) lines.push("Default: " + esc(info.def.provider) + " / " + esc(info.def.model)); + lines.push("", "Tap 🧠 Model to pick one, or send a message to start chatting."); + return lines.join("\n"); + } + if (args.length === 0) { + const info = await currentModelInfo(r.ctx, r.state, r.chatId); + const lines = []; + if (info.current?.provider) { + lines.push("This chat: " + esc(info.current.provider) + " / " + esc(info.current.model) + (info.current.reasoningEffort ? " Β· " + esc(info.current.reasoningEffort) : "")); + } else { + lines.push("This chat: using the default"); + } + if (info.def?.provider) { + lines.push("Default: " + esc(info.def.provider) + " / " + esc(info.def.model) + (info.def.reasoningEffort ? " Β· " + esc(info.def.reasoningEffort) : "")); + } + lines.push("", "/models to browse Β· /model <id> to switch Β· /model max for deepest thinking"); + return lines.join("\n"); + } + const first = args[0].toLowerCase(); + if (/^\d+$/.test(first)) { + const list = r.state.chatState(r.chatId).lastModels ?? []; + const pick = list[Number(first) - 1]; + if (!pick) return "That number isn't on the last model list β€” tap 🧠 Model again."; + return setSessionModel(r, sessionId, { provider: pick.provider, model: pick.model }); + } + if (first === "max" || first === "deep" || first === "deepest") { + return setSessionModel(r, sessionId, { reasoningEffort: "max" }); + } + if (first === "default") { + const prov = args[1]; + const model = args[2]; + const effort = args[3]; + if (!prov || !model) return "usage: /model default <provider> <model> [effort]"; + const settings = r.ctx.get("settings"); + if (!settings || typeof settings.replace !== "function") return "Settings service unavailable."; + await settings.replace("agent-default-model", { + provider: prov, + model, + ...(effort ? { reasoningEffort: effort } : {}), + }); + return "βœ… Default model set: " + esc(prov) + " / " + esc(model) + (effort ? " Β· " + esc(effort) : "") + "\nApplies to new chats."; + } + // /model [effort] or /model [effort] + // also accepts provider/model as a single token. + const catalog = await getCatalog(r.ctx); + const flat = flattenCatalog(catalog); + let provider = null; + let model = null; + let effort = null; + const raw = String(args[0] ?? ""); + if (raw.includes("/") && args.length === 1) { + const slash = raw.indexOf("/"); + provider = raw.slice(0, slash); + model = raw.slice(slash + 1); + } else if (args.length >= 2 && (catalog.some((g) => (g.id ?? g.group?.id) === args[0]) || flat.some((m) => m.provider === args[0]))) { + provider = args[0]; + model = args[1]; + effort = args[2]; + } else { + model = args[0]; + effort = args[1]; + } + if (!provider && model) { + const hit = flat.find((m) => m.id === model) ?? flat.find((m) => m.id.toLowerCase() === String(model).toLowerCase()); + provider = hit?.provider ?? null; + if (hit) model = hit.id; + } + if (!provider || !model) return "I couldn't find that model β€” tap 🧠 Model and pick one from the list."; + return setSessionModel(r, sessionId, { provider, model, ...(effort ? { reasoningEffort: effort } : {}) }); +} + +async function setSessionModel(r, sessionId, { provider, model, reasoningEffort }) { + if (!sessionId) { + const settings = r.ctx.get("settings"); + if (!settings || typeof settings.replace !== "function") return "Open a chat first (tap πŸ’¬ New chat), then pick a model."; + if (!provider || !model) return "Tap 🧠 Model and pick one from the list."; + await settings.replace("agent-default-model", { + provider, + model, + ...(reasoningEffort ? { reasoningEffort } : {}), + }); + return "βœ… Next chat will use " + esc(model) + "\nTap πŸ’¬ New chat or just send a message."; + } + let current = {}; + try { + const r2 = await callApi(r.ctx, "sessions", "models", { sessionId }); + current = r2?.current ?? {}; + } catch {} + const payload = { + sessionId, + provider: provider ?? current.provider, + model: model ?? current.model, + ...(reasoningEffort ? { reasoningEffort } : {}), + }; + if (!payload.provider || !payload.model) return "This chat has no model yet β€” tap 🧠 Model and pick one."; + try { + await callApi(r.ctx, "sessions", "selectModel", payload); + } catch (error) { + const msg = String(error.message ?? error); + if (/image input|does not accept image|does not support image/i.test(msg)) { + return "⚠️ This chat already has photos, so it needs a vision model.\nPick one that can see images, or tap πŸ’¬ New chat and switch there."; + } + return "⚠️ Couldn't switch model: " + esc(truncate(msg, 220)); + } + return "βœ… Switched to " + esc(payload.model) + "\n" + esc(payload.provider) + (payload.reasoningEffort ? " Β· " + esc(payload.reasoningEffort) : "") + "\nSend a message to use it."; +} + +const CHATS_PAGE_SIZE = 10; + +async function cmdSessions(r) { + const sessions = await r.state.listSessions(); + if (!sessions) return "No chats yet β€” tap πŸ’¬ New chat and say hi!"; + const visible = sessions.filter((item) => item.origin !== "subagent"); + if (visible.length === 0) return "No chats yet β€” tap πŸ’¬ New chat and say hi!"; + const active = r.state.chatState(r.chatId).sessionId; + const chatState = r.state.chatState(r.chatId); + chatState.sessionIds = visible.map((item) => item.sessionId); + chatState.lastListAt = Date.now(); + r.state.saveState(); + const totalPages = Math.max(1, Math.ceil(visible.length / CHATS_PAGE_SIZE)); + let page = 1; + const rawPage = r.args?.[0]; + if (rawPage != null && /^\d+$/.test(String(rawPage).trim())) { + page = Number(String(rawPage).trim()); + if (page < 1) page = 1; + if (page > totalPages) page = totalPages; + } + const start = (page - 1) * CHATS_PAGE_SIZE; + const show = visible.slice(start, start + CHATS_PAGE_SIZE); + const lines = ["πŸ“‹ Your chats", "reply with a number to open one:"]; + show.forEach((item, index) => { + const mark = item.sessionId === active ? " πŸ‘ˆ" : ""; + const status = item.running ? "πŸƒ" : item.blank ? "⬜" : "πŸ’€"; + const title = item.projections?.values?.title; + // Shorten the path to its last segment β€” full paths clutter mobile chats. + const folder = item.cwd ? basename(item.cwd) : ""; + lines.push( + (start + index + 1) + ". " + status + " " + esc(title ?? "untitled") + "" + mark + "\n " + fmtAge(item.updatedAt) + (folder ? " Β· " + esc(folder) : "") + "", + ); + }); + lines.push("Page " + page + "/" + totalPages + ""); + lines.push("πŸ’¬ /new starts a fresh chat"); + if (totalPages > 1) { + const row = []; + if (page > 1) row.push({ text: "◀️", callback_data: "/chats " + (page - 1) }); + if (page < totalPages) row.push({ text: "▢️", callback_data: "/chats " + (page + 1) }); + return { text: lines.join("\n"), keyboard: { inline_keyboard: [row] } }; + } + return lines.join("\n"); +} + +async function cmdOpen(r) { + const arg = r.args[0]; + if (!arg) { + const state = r.state.chatState(r.chatId); + return state.sessionId ? "current chat: " + state.sessionId + "" : "No chat open yet β€” use πŸ’¬ New chat or πŸ“‹ My chats"; + } + let sessionId = null; + const chatState = r.state.chatState(r.chatId); + if (/^\d+$/.test(arg) && chatState.sessionIds?.length > 0 && Date.now() - (chatState.lastListAt ?? 0) < 10 * 60_000) { + const index = Number(arg) - 1; + sessionId = chatState.sessionIds[index] ?? null; + if (!sessionId) return "That number isn't in the list β€” send /chats again"; + } + if (!sessionId) { + sessionId = await r.state.resolveSessionId(r.chatId, arg); + } + if (!sessionId) return "I couldn't find that chat: " + esc(arg) + " β€” try /chats"; + chatState.sessionId = sessionId; + r.state.saveState(); + const sessions = await r.state.listSessions(); + const title = sessions?.find((item) => item.sessionId === sessionId)?.projections?.values?.title; + return "βœ… Opened " + esc(title ?? sessionId.slice(0, 12) + "…") + "\nJust type your message β€” the reply streams right here. πŸ’¬"; +} + +async function cmdNew(r) { + const cwd = r.args[0] || r.state.config.workspaceRoot || process.cwd(); + const created = await callApi(r.ctx, "sessions", "create", { cwd }); + const sessionId = created.sessionId; + r.state.chatState(r.chatId).sessionId = sessionId; + r.state.saveState(); + return "βœ… New chat ready!\nSend me your first message β€” the AI's reply will stream right here. 😊"; +} + +export async function cmdMsg(r) { + // Plain text (no leading "/") β†’ the WHOLE message is the text. r.rest would + // drop the first word; /msg keeps using r.rest (the part after /msg). + let text = r.text.startsWith("/") ? r.rest : r.text; + if (!text) return "What should I tell your AI? Just type your message πŸ™‚"; + // Friendly default: if no chat is open yet, create one automatically. + const cs = r.state.chatState(r.chatId); + if (!cs.sessionId) { + try { + const created = await callApi(r.ctx, "sessions", "create", { cwd: r.state.config.workspaceRoot || process.cwd() }); + cs.sessionId = created.sessionId; + r.state.saveState(); + } catch {} + } + const agents = r.ctx.get("agents"); + const busy = agents?.get(cs.sessionId)?.status === "running"; + if (busy) { + // Offer a choice: steer it in now, or queue it for after the current work. + cs.pendingText = text; + cs.pendingAt = Date.now(); + r.state.saveState(); + return { + text: "πŸ€– The AI is working on something. Send this message now (steer it in) or queue it?", + keyboard: BUSY_KEYBOARD, + }; + } + await promptSession(r, "queue", text, undefined); + return null; +} + +async function cmdQueue(r) { + const sessionId = await r.state.resolveSessionId(r.chatId, ""); + if (!sessionId) return "No chat is open β€” tap πŸ’¬ New chat first."; + const agent = r.ctx.get("agents")?.get(sessionId); + const nextTurn = agent?.inbox?.state?.["next-turn"] ?? []; + const nextStep = agent?.inbox?.state?.["next-step"] ?? []; + const items = [ + ...nextTurn.map((m) => ({ id: m.id, kind: "queued", text: contentText(m.content) })), + ...nextStep.map((m) => ({ id: m.id, kind: "steering", text: contentText(m.content) })), + ]; + if (items.length === 0) return "Nothing is queued β€” the AI is idle. Just send a message!"; + const cs = r.state.chatState(r.chatId); + cs.queueIds = items.map((item) => item.id); + cs.queueListAt = Date.now(); + r.state.saveState(); + const lines = ["πŸ“₯ Queued messages\n─────── β‹†β‹…β˜†β‹…β‹† ───────"]; + items.forEach((item, index) => { + const tag = item.kind === "steering" ? "⚑steering" : "⏳queued"; + lines.push((index + 1) + ". " + tag + " " + esc(truncate(item.text, 90))); + }); + lines.push( + "─────── β‹†β‹…β˜†β‹…β‹† ───────", + "β–Ά /steer <n> β€” push into the running turn", + "✏ /edit <n> <new> Β· πŸ—‘ /remove <n>", + ); + return truncate(lines.join("\n"), 3000); +} + +function queueItemId(r, arg) { + const cs = r.state.chatState(r.chatId); + if (/^\d+$/.test(arg) && cs.queueIds?.length > 0 && Date.now() - (cs.queueListAt ?? 0) < 10 * 60_000) { + return cs.queueIds[Number(arg) - 1] ?? null; + } + return arg; +} + +async function cmdEdit(r) { + const sessionId = await r.state.resolveSessionId(r.chatId, ""); + if (!sessionId) return "No chat is open."; + const itemId = queueItemId(r, r.args[0] ?? ""); + const text = r.args.slice(1).join(" "); + if (!itemId) return "/edit <n> <new text> β€” see /queue for numbers"; + if (!text) return "What should the queued message say instead? /edit <n> <new text>"; + await callApi(r.ctx, "sessions", "updateQueue", { sessionId, itemId, action: { kind: "edit", content: [{ type: "text", text }] } }); + return "✏️ Updated the queued message."; +} + +async function cmdRemove(r) { + const sessionId = await r.state.resolveSessionId(r.chatId, ""); + if (!sessionId) return "No chat is open."; + const itemId = queueItemId(r, r.args[0] ?? ""); + if (!itemId) return "/remove <n> β€” see /queue for numbers"; + await callApi(r.ctx, "sessions", "updateQueue", { sessionId, itemId, action: { kind: "remove" } }); + return "πŸ—‘ Removed the queued message."; +} + +async function cmdSteer(r) { + const text = r.rest; + if (!text) return "/steer <text> β€” steer the running turn, or /steer <n> for a queued message"; + // A bare number right after /queue steers that queued message into the turn. + if (/^\d+$/.test(text) && r.state.chatState(r.chatId).queueIds?.length > 0) { + const sessionId = await r.state.resolveSessionId(r.chatId, ""); + if (!sessionId) return "No chat is open."; + const itemId = queueItemId(r, text); + if (!itemId) return "That queued message is gone β€” /queue to refresh."; + await callApi(r.ctx, "sessions", "updateQueue", { sessionId, itemId, action: { kind: "steer" } }); + return "βœ… Steered the queued message into the running turn."; + } + await promptSession(r, "steer", text, undefined); + return "▢️ Sent it straight into the AI's current turn."; +} + +async function cmdSteerNow(r) { + const cs = r.state.chatState(r.chatId); + const text = cs.pendingText; + if (!text) return "Nothing pending β€” just type your message."; + cs.pendingText = ""; + r.state.saveState(); + await promptSession(r, "steer", text, undefined); + return "▢️ Sent it straight into the AI's current turn."; +} + +async function cmdQueueMsg(r) { + const cs = r.state.chatState(r.chatId); + const text = cs.pendingText; + if (!text) return "Nothing pending β€” just type your message."; + cs.pendingText = ""; + r.state.saveState(); + await promptSession(r, "queue", text, undefined); + return "⏳ Queued β€” the AI will pick it up next."; +} + +async function promptSession(r, mode, text, sessionArg) { + const sessionId = await r.state.resolveSessionId(r.chatId, sessionArg); + if (!sessionId) return "no active session β€” /open <id> or /new first"; + if (r.state.isRecentPrompt(sessionId, text)) return "duplicate prompt skipped"; + let timeZone = "UTC"; + try { timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch {} + let result = null; + try { + result = await callApi(r.ctx, "sessions", "prompt", { + sessionId, + mode, + content: [{ type: "text", text }], + clientTimeZone: timeZone, + }); + } catch (error) { + if (error.code !== "invocation-unavailable" && error.code !== "no api surface") { + throw error; + } + // api-proxy absent (base-only profiles): fall back to the live agent's + // inbox directly (with resume), mirroring what the api-proxy does. + result = await promptSessionDirect(r.ctx, sessionId, mode, text); + } + r.state.notePrompt(sessionId, text); + // Auto-enable notifications for this session so the agent's reply arrives. + const chatState = r.state.chatState(r.chatId); + const notifyChanged = chatState.notify === "off"; + if (notifyChanged) { + chatState.notify = "session"; + r.state.saveState(); + } + if (result?.command) { + return "⚑ command " + esc(result.command.kind) + (result.command.text ? ": " + esc(result.command.text) : ""); + } + // No ack β€” the reply arrives as a normal threaded chat message. + return null; +} + +/** Direct agent-inbox prompt for profiles without the web api-proxy. */ +async function promptSessionDirect(ctx, sessionId, mode, text) { + const agents = ctx.get("agents"); + if (!agents) throw new Error("no agent service mounted"); + let agent = agents.get(sessionId); + if (!agent && typeof agents.resume === "function") { + // Session not attached after a harness restart: resume it like the + // api-proxy would (seed model from the agent-default-model service). + let agentOptions = {}; + try { + const modelService = ctx.get("agentDefaultModel"); + const selection = modelService?.currentSelection?.(); + if (selection?.provider && selection?.model) { + agentOptions = { + provider: selection.provider, + model: selection.model, + ...(selection.reasoningEffort ? { reasoningEffort: selection.reasoningEffort } : {}), + }; + } + } catch {} + const resumed = await agents.resume({ resumeSessionId: sessionId, agentOptions }); + agent = resumed?.agent; + } + if (!agent) throw new Error("session has no live agent (not attached)"); + const message = { + id: randomUUID(), + role: "user", + content: [{ type: "text", text }], + source: { kind: "user" }, + }; + if (mode === "steer") { + if (typeof agent.steer !== "function") throw new Error("agent does not accept steering"); + agent.steer(message); + } else if (typeof agent.followup === "function") { + agent.followup(message); + } else { + throw new Error("agent does not accept followups"); + } + return { accepted: true }; +} + +async function cmdStop(r) { + const sessionId = await r.state.resolveSessionId(r.chatId, r.args[0]); + if (!sessionId) return "no active session"; + await callApi(r.ctx, "sessions", "cancel", { sessionId }); + return "⏹ cancel requested for " + shortId(sessionId) + ""; +} + +async function cmdJobs(r) { + const jobs = await collectJobs(r.ctx); + if (jobs.length === 0) return "no background jobs"; + return jobs + .map((job) => { + const icon = job.status === "running" ? "πŸƒ" : job.status === "stopping" ? "⏳" : job.status === "completed" ? "βœ…" : job.status === "killed" ? "⏹" : "❌"; + const detail = job.detail ? " β€” " + esc(job.detail) : ""; + return icon + " " + esc(job.id) + " " + esc(job.label) + " [" + job.status + "]" + detail + " (" + fmtAge(job.startedAt) + ")"; + }) + .join("\n"); +} + +async function cmdKill(r) { + const jobId = r.args[0]; + if (!jobId) return "/kill <jobId>"; + const jobs = r.ctx.get("jobs"); + const agents = r.ctx.get("agents"); + const sessions = r.ctx.get("sessions"); + for (const session of sessions.list()) { + const agent = agents.get(session.id); + if (!agent) continue; + try { + const found = jobs.list(agent).find((job) => job.id === jobId); + if (found) { + const outcome = jobs.kill(jobId, agent, "killed from telegram remote"); + return "⏹ " + esc(jobId) + ": " + outcome; + } + } catch (error) { + if (/not found|no such/i.test(error.message)) continue; + throw error; + } + } + return "job not found: " + esc(jobId) + " (use /jobs)"; +} + +export async function getGoal(ctx, state, chatId) { + const goals = ctx.get("goals"); + if (!goals) return null; + const agents = ctx.get("agents"); + const sessionId = await state.resolveSessionId(chatId, ""); + if (!sessionId) return null; + const agent = agents.get(sessionId); + if (!agent) return null; + try { + return goals.get(agent) ?? null; + } catch { + return null; + } +} + +async function cmdGoal(r) { + const goals = r.ctx.get("goals"); + const agents = r.ctx.get("agents"); + if (!goals || !agents) return "goal service unavailable"; + const sessionId = await r.state.resolveSessionId(r.chatId, ""); + if (!sessionId) return "no active session β€” /open <id> first"; + const agent = agents.get(sessionId); + if (!agent) return "session " + shortId(sessionId) + " has no live agent"; + const action = r.args[0] ?? "get"; + try { + switch (action) { + case "get": + case "status": { + const goal = goals.get(agent); + if (!goal) return "no active goal for this session"; + let text = "Goal " + goal.id + "\nphase " + goal.phase + " Β· rounds " + goal.roundsStarted + "/" + goal.maxGoalRounds + "\nrevision " + goal.revision + "\n\n" + esc(goal.objective); + if (goal.blockedReason?.message) text += "\n\nblocked: " + esc(goal.blockedReason.message); + return text; + } + case "create": { + const objective = r.rest.replace(/^create\s+/, "").trim(); + if (!objective) return "/goal create <objective>"; + const ref = goals.create(agent, { objective }); + return "goal created: " + ref.id + " r" + ref.revision; + } + case "pause": { + const goal = goals.get(agent); + if (!goal) return "no active goal"; + const ref = goals.pause(agent, { id: goal.id, revision: goal.revision }); + return "⏸ paused " + ref.id + " r" + ref.revision; + } + case "resume": { + const goal = goals.get(agent); + if (!goal) return "no active goal"; + const ref = goals.resume(agent, { id: goal.id, revision: goal.revision }); + return "β–Ά resumed " + ref.id + " r" + ref.revision; + } + case "complete": { + const goal = goals.get(agent); + if (!goal) return "no active goal"; + const ref = goals.complete(agent, { id: goal.id, revision: goal.revision }); + return "βœ… completed " + ref.id + " r" + ref.revision; + } + case "blocked": { + const reason = r.rest.replace(/^blocked\s+/, "").trim(); + if (!reason) return "/goal blocked <reason>"; + const goal = goals.get(agent); + if (!goal) return "no active goal"; + const ref = goals.blocked(agent, { id: goal.id, revision: goal.revision }, reason); + return "β›” marked blocked " + ref.id + ""; + } + case "clear": { + if (typeof goals.clear === "function") { + goals.clear(agent); + } else { + await callApi(r.ctx, "goals", "clear", { sessionId }); + } + return "🧹 Goal cleared."; + } + default: + return "unknown action: get|create|pause|resume|complete|blocked|clear"; + } + } catch (error) { + return "goal error: " + esc(error.message); + } +} + +async function cmdAgents(r) { + const sessionId = await r.state.resolveSessionId(r.chatId, r.args[0]); + if (!sessionId) return "no active session β€” /open <id> first"; + const result = await callApi(r.ctx, "subagents", "list", { parentSessionId: sessionId }); + const entries = result?.entries ?? []; + if (entries.length === 0) return "no subagents under " + shortId(sessionId); + return entries + .map((entry) => { + if (entry.kind === "diagnostic") return "⚠ " + entry.id + " diagnostic " + entry.reason; + const icon = entry.activity === "running" ? "πŸƒ" : "πŸ’€"; + return icon + " " + entry.id + " " + esc(entry.label ?? "") + " [" + entry.mode + "]" + (entry.hasChildren ? " ⊳" : ""); + }) + .join("\n"); +} + +async function cmdSubagentSend(r) { + const childId = r.args[0]; + const text = r.args.slice(1).join(" "); + if (!childId || !text) return "/send <agentId> <text>"; + const sessionId = await r.state.resolveSessionId(r.chatId, ""); + if (!sessionId) return "no active parent session β€” /open <id> first"; + const result = await callApi(r.ctx, "subagents", "prompt", { + parentSessionId: sessionId, + childSessionId: childId, + content: [{ type: "text", text }], + }); + return "πŸ“¨ delivered to " + shortId(childId) + " (message " + esc(result?.messageId ?? "?") + ")"; +} + +async function cmdShell(r) { + const shell = r.ctx.get("shell"); + if (!shell) return "shell service unavailable"; + const command = r.rest; + if (!command) return "/cmd <powershell>"; + let timeoutMs = 60_000; + if (/^\d+$/.test(r.args[0] ?? "")) timeoutMs = Math.min(Math.max(Number(r.args[0]), 5_000), 300_000); + const request = { + command, + workdir: r.state.config.workspaceRoot || process.cwd(), + timeoutMs, + stdoutMaxBytes: r.state.config.maxOutputBytes, + }; + const spec = typeof shell.resolve === "function" ? shell.resolve(request) : request; + const result = await shell.run(spec); + const out = truncate(result.stdout?.text ?? "", 3500); + const err = truncate(result.stderr?.text ?? "", 1500); + const meta = []; + if (result.exitCode != null) meta.push("exit code " + result.exitCode); + if (result.signal) meta.push("signal " + result.signal); + if (result.timedOut) meta.push("timed out"); + if (result.sandbox?.denied) meta.push("sandbox denied (" + result.sandbox.mode + ")"); + const parts = []; + if (out) parts.push("
" + esc(out) + "
"); + if (err) parts.push("
" + esc(err) + "
"); + parts.push(meta.length ? "" + esc(meta.join(" Β· ")) + "" : "ok"); + return parts.join("\n"); +} + +async function cmdFs(r) { + const fs = r.ctx.get("fs"); + if (!fs) return "fs service unavailable"; + const sub = (r.args[0] ?? "ls").toLowerCase(); + const action = sub; + const restArgs = r.args.slice(1); + const cwd = r.state.config.workspaceRoot || process.cwd(); + switch (action) { + case "ls": + case "list": { + const rawPath = restArgs.join(" ").trim() || "."; + const target = await fs.resolve(isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath), { cwd }); + const entries = await fs.listDir(target); + const dirs = entries.filter((entry) => entry.type === "directory"); + const files = entries.filter((entry) => entry.type !== "directory"); + const lines = [ + "" + esc(target.displayPath ?? rawPath) + " β€” " + dirs.length + " dirs, " + files.length + " files", + ...dirs.map((entry) => "πŸ“ " + esc(entry.name)), + ...files.map((entry) => "πŸ“„ " + esc(entry.name) + (entry.size != null ? " (" + entry.size + " B)" : "")), + ]; + return truncate(lines.join("\n"), 3500); + } + case "read": + case "cat": { + const rawPath = restArgs.join(" ").trim(); + if (!rawPath) return "/fs read <path>"; + const target = await fs.resolve(isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath), { cwd }); + const stat = await fs.stat(target); + if (!stat) return "not found: " + esc(target.displayPath ?? rawPath); + if (stat.type !== "file") return "not a file: " + esc(target.displayPath ?? rawPath); + const text = await fs.readText(target); + return "" + esc(target.displayPath ?? rawPath) + " (" + stat.size + " B)\n
" + esc(truncate(text, 3500)) + "
"; + } + case "write": { + const sep = restArgs.indexOf("|"); + if (sep < 0) return "/fs write <path> | <content> β€” content after the pipe"; + const path = restArgs.slice(0, sep).join(" ").trim(); + const content = restArgs.slice(sep + 1).join(" "); + const target = await fs.resolve(isAbsolute(path) ? path : resolve(cwd, path), { cwd }); + const outcome = await fs.writeText(target, content); + return "✍ " + outcome.operation + " " + esc(target.displayPath) + " (v" + outcome.version + ")"; + } + case "rm": + case "del": { + const rawPath = restArgs.join(" ").trim(); + if (!rawPath) return "/fs rm <path>"; + const target = await fs.resolve(isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath), { cwd }); + try { + await fs.delete(target); + return "πŸ—‘ deleted " + esc(target.displayPath ?? rawPath); + } catch (error) { + return "delete failed: " + esc(error.message); + } + } + case "stat": { + const rawPath = restArgs.join(" ").trim(); + if (!rawPath) return "/fs stat <path>"; + const target = await fs.resolve(isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath), { cwd }); + const stat = await fs.stat(target); + if (!stat) return "not found: " + esc(target.displayPath ?? rawPath); + return "" + esc(target.displayPath ?? rawPath) + "\ntype " + stat.type + " Β· size " + stat.size + " B Β· version " + esc(stat.version); + } + default: + return "usage: /fs ls|read|write|rm|stat <path>"; + } +} + +async function cmdLog(r) { + const sessions = r.ctx.get("sessions"); + const sessionQuery = r.ctx.get("sessionQuery"); + let sessionId = r.args[0] ?? ""; + let count = 8; + if (/^\d+$/.test(sessionId)) { + count = Number(sessionId); + sessionId = ""; + } + if (sessionId && /^\d+$/.test(r.args[1] ?? "")) count = Number(r.args[1]); + sessionId = await r.state.resolveSessionId(r.chatId, sessionId); + if (!sessionId) return "no active session β€” /open <id> first"; + let events; + try { + const live = sessions.get(sessionId); + if (live) { + events = live.events; + } else if (sessionQuery && typeof sessionQuery.readSession === "function") { + const loaded = await sessionQuery.readSession(sessionId); + events = loaded.events; + } else if (sessionQuery && typeof sessionQuery.load === "function") { + const loaded = await sessionQuery.load(sessionId); + events = loaded.events; + } else { + return "session " + shortId(sessionId) + " not attached and sessionQuery unavailable"; + } + } catch (error) { + return "load failed: " + esc(error.message); + } + const shown = []; + for (const event of events) { + if (event.type === "user/message") { + const text = contentText(event.data.content); + if (text) shown.push("πŸ§‘ " + esc(truncate(text, 300))); + } else if (event.type === "assistant/message") { + const text = contentText(event.data.message?.content, { textOnly: true }); + if (text) shown.push("πŸ€– " + esc(truncate(text, 500))); + } else if (event.type === "tool/call") { + shown.push("πŸ”§ " + esc(event.data.name) + " " + esc(truncate(String(event.data.arguments ?? ""), 120))); + } else if (event.type === "tool/result" && event.data.error) { + shown.push("⚠ " + esc(event.data.error.name ?? "error") + ": " + esc(truncate(String(event.data.error.message ?? ""), 160))); + } else if (event.type === "turn/end") { + shown.push("⏹ turn ended: " + esc(event.data.reason?.kind ?? "?")); + } + } + if (shown.length === 0) return "" + sessionId + " β€” no surface events"; + const tail = shown.slice(-count).join("\n"); + return "" + esc(sessionId) + " (last " + Math.min(count, shown.length) + " of " + shown.length + ")\n" + tail; +} + +async function cmdRaw(r) { + const endpoint = r.args[0] ?? ""; + const [namespace, method] = endpoint.includes(".") ? endpoint.split(".") : endpoint.split("/"); + if (!namespace || !method) return "/raw <namespace.method> [json args]"; + let args = {}; + const jsonPart = r.rest.replace(endpoint, "").trim(); + if (jsonPart) { + try { + args = JSON.parse(jsonPart); + } catch (error) { + return "invalid JSON args: " + esc(error.message); + } + } + let result; + try { + result = await callApi(r.ctx, namespace, method, args); + } catch (error) { + if (error.code !== "no api surface") throw error; + const gateway = r.ctx.get("typertGateway"); + if (!gateway) throw new Error("no api surface for " + endpoint); + result = await invoke(gateway, namespace, method, args); + } + return "" + esc(endpoint) + " β†’\n
" + esc(pretty(result, 3500)) + "
"; +} + +async function cmdApi(r) { + const gateway = r.ctx.get("typertGateway"); + const typert = r.ctx.get("typert"); + const endpoints = new Set(); + try { + const local = typert?.local; + if (local) { + if (typeof local.keys === "function") for (const key of local.keys()) endpoints.add(key); + if (local instanceof Map) for (const key of local.keys()) endpoints.add(key); + } + } catch {} + try { + if (typeof gateway?.collectSrcClaims === "function") { + for (const claim of gateway.collectSrcClaims()) endpoints.add(claim); + } + } catch {} + if (endpoints.size === 0) return "no endpoints discovered"; + const sorted = [...endpoints].sort(); + return "" + endpoints.size + " endpoints\n" + esc(sorted.join("\n")) + "".slice(0, 3900); +} + +async function cmdEval(r) { + if (!r.state.config.allowEval) return "/eval disabled (allowEval=false)"; + const code = r.rest; + if (!code) return "/eval <js> β€” runs with ctx, state, gateway, jobs, fs, shell, sessions, agents, goals in scope"; + const sandbox = { + ctx: r.ctx, + state: r.state, + gateway: r.ctx.get("typertGateway"), + apiProxy: r.ctx.get("apiProxy"), + callApi: (domain, method, args) => callApi(r.ctx, domain, method, args), + jobs: r.ctx.get("jobs"), + fs: r.ctx.get("fs"), + shell: r.ctx.get("shell"), + sessions: r.ctx.get("sessions"), + agents: r.ctx.get("agents"), + goals: r.ctx.get("goals"), + settings: r.ctx.get("settings"), + process, + }; + const fn = new Function(...Object.keys(sandbox), "return (async () => {\n" + code + "\n})()"); + const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("eval timeout (30s)")), 30_000)); + const result = await Promise.race([fn(...Object.values(sandbox)), timeout]); + return "
" + esc(pretty(result, 3000)) + "
"; +} + +async function cmdConfig(r) { + const parts = []; + for (const file of [join(dshHome(), "settings.yaml"), join(dshHome(), "profiles", "web", "cordis.patch.yml")]) { + if (!existsSync(file)) continue; + try { + const text = readFileSync(file, "utf8"); + parts.push("" + esc(file.replace(dshHome(), "~")) + "\n
" + esc(truncate(text, 2500)) + "
"); + } catch (error) { + parts.push("" + esc(file) + " β€” read failed: " + esc(error.message)); + } + } + if (parts.length === 0) return "no config files found"; + return parts.join("\n"); +} + +async function cmdNotify(r) { + const state = r.state.chatState(r.chatId); + const arg = (r.args[0] ?? "").toLowerCase(); + const modes = { + on: "session β€” replies of this chat's session", + all: "all β€” every session's activity", + off: "off β€” nothing until you ask", + }; + if (arg === "on" || arg === "all" || arg === "off") { + state.notify = arg; + r.state.saveState(); + return "πŸ”” Notify: " + arg + "\n" + modes[arg] + ""; + } + if (arg === "status") { + return "πŸ”” Notify: " + state.notify + "" + (state.sessionId ? " (active session " + shortId(state.sessionId) + ")" : ""); + } + return "πŸ”” Notify\n/notify on β€” this chat's session\n/notify all β€” everything\n/notify off β€” silence"; +} + +async function cmdReboot(r) { + const yes = r.args.includes("--yes") || r.args.includes("yes"); + if (!yes) return "/reboot --yes β€” this restarts the whole harness process"; + const launched = await launchRestart(r.state); + if (!launched.ok) { + r.state.log("restart failed to launch: " + launched.error); + return "❌ restart failed to launch: " + esc(launched.error); + } + await r.state.bot.send(r.chatId, "πŸ”„ restarting harness (pid " + process.pid + ")… bot will re-appear in ~20s"); + r.state.log("/reboot by " + r.userId); + // Fallback: if the killer script somehow fails, exit anyway after a grace + // period (the detached script relaunches the harness either way). + setTimeout(() => { + try { process.exit(0); } catch {} + }, 15_000); + return null; +} + +/** + * Write and spawn the detached restart script. Resolves with + * { ok: true } only when the child process actually started; spawn errors + * (missing shell, sandbox denial, …) resolve with { ok: false, error }. + */ +async function launchRestart(state) { + try { + writeRestartScript(state); + const pwshPath = resolvePwsh(); + const child = spawn(pwshPath, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", state.restartScriptPath], { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + const failed = await new Promise((resolve) => { + child.once("error", (error) => resolve(error)); + // The child outlives us (detached); after 2s treat it as started. + setTimeout(() => resolve(null), 2_000); + }); + if (failed) return { ok: false, error: failed.message }; + child.unref(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error.message }; + } +} + +async function cmdShutdown(r) { + const yes = r.args.includes("--yes") || r.args.includes("yes"); + if (!yes) return "/shutdown --yes β€” stops the whole harness process"; + r.state.log("/shutdown by " + r.userId); + await r.state.bot.send(r.chatId, "⏻ shutting down harness…"); + setTimeout(() => { + try { process.exit(0); } catch {} + }, 2_000); + return null; +} + +/* ── reboot helpers ── */ + +function resolvePwsh() { + const candidates = [ + join(process.env.ProgramFiles ?? "C:\\Program Files", "PowerShell", "7", "pwsh.exe"), + join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), + ]; + for (const candidate of candidates) if (existsSync(candidate)) return candidate; + return "powershell.exe"; +} + +function resolveNpx() { + const candidates = [ + join(dirname(process.execPath), "npx.cmd"), + join(process.env.APPDATA ?? "", "npm", "npx.cmd"), + ]; + for (const candidate of candidates) if (existsSync(candidate)) return candidate; + return "npx"; +} + +export function writeRestartScript(state) { + // Relaunch from the SAME directory the harness was originally launched + // from (process.cwd()), NOT the workspace root. The launch directory + // decides which .env layers dsh loads, and a workspace .env may carry + // reserved vars (e.g. DSH_MAX_TOKENS) that make boot crash when loaded. + const cwd = process.cwd(); + const npx = resolveNpx(); + const log = join(dshHome(), "telegram-remote-restart.log"); + const outLog = join(dshHome(), "dsh-web.out.log"); + const errLog = join(dshHome(), "dsh-web.err.log"); + const pid = process.pid; + const ppid = process.ppid; + // Detect the port THIS harness was launched with so /reboot stays on it + // (default 3080). Never kill other dsh instances the user may be running. + let port = 3080; + const portIdx = process.argv.findIndex((a) => a === "--port" || a === "-p"); + if (portIdx >= 0 && process.argv[portIdx + 1]) port = Number(process.argv[portIdx + 1]) || 3080; + const script = [ + '$log = ' + "'" + log + "'", + 'Add-Content $log "restart: killing harness (pid ' + pid + ', ppid ' + ppid + ') on port ' + port + ' at $(Get-Date)"', + 'Start-Sleep -Seconds 3', + // Graceful first: SIGINT lets the harness checkpoint the session logs + // cleanly (no mid-write corruption). Force-kill only as a fallback. + 'Add-Content $log "sending graceful SIGINT to ' + pid + '"', + "& 'C:\\nvm4w\\nodejs\\node.exe' -e \"process.kill(" + pid + ", 'SIGINT')\" 2>\$null", + 'for ($i = 0; $i -lt 20; $i++) {', + ' Start-Sleep -Seconds 1', + ' if (-not (Get-Process -Id ' + pid + ' -ErrorAction SilentlyContinue)) { Add-Content $log ("exited gracefully after " + ($i + 1) + "s"); break }', + '}', + 'foreach ($p in @(' + pid + ', ' + ppid + ')) {', + ' if (Get-Process -Id $p -ErrorAction SilentlyContinue) {', + ' try { Stop-Process -Id $p -Force -ErrorAction Stop; Add-Content $log ("force-killed " + $p) } catch { Add-Content $log ("kill failed " + $p) }', + ' }', + '}', + 'Start-Sleep -Seconds 2', + 'Add-Content $log "relaunching npx=' + npx + " cwd=" + cwd + " port=" + port + ' at $(Get-Date)"', + "Start-Process -FilePath '" + npx + "' -ArgumentList '@deepseek-ai/dsh','web','--port','" + port + "' -WorkingDirectory '" + cwd + "' -WindowStyle Hidden -RedirectStandardOutput '" + outLog + "' -RedirectStandardError '" + errLog + "'", + "for ($i = 0; $i -lt 90; $i++) {", + " Start-Sleep -Seconds 2", + " try {", + " $r = Invoke-WebRequest -Uri 'http://127.0.0.1:" + port + "' -UseBasicParsing -TimeoutSec 3", + ' Add-Content $log ("UP status=" + $r.StatusCode + " at " + (Get-Date))', + " exit 0", + " } catch {}", + "}", + 'Add-Content $log ("FAILED: harness did not come up at " + (Get-Date))', + "exit 1", + ].join("\r\n"); + writeFileSync(state.restartScriptPath, script, "utf8"); +} diff --git a/packages/telegram-remote/lib/features.js b/packages/telegram-remote/lib/features.js new file mode 100644 index 0000000..27a47cc --- /dev/null +++ b/packages/telegram-remote/lib/features.js @@ -0,0 +1,272 @@ +/** + * Full-harness feature commands: sessions, workspaces, subagents, presets, + * skills, plugins, settings, credentials, permissions, exports. + */ + +import { join } from "node:path"; +import { writeFileSync } from "node:fs"; +import { esc } from "./bot.js"; +import { callApi, contentText, dshHome, fmtAge, pretty, shortId, truncate } from "./util.js"; + +/* ── session management ── */ + +export async function cmdRename(r) { + const sessionId = await r.state.resolveSessionId(r.chatId, r.args[0]); + const title = r.args.slice(1).join(" ").trim(); + if (!sessionId || !title) return "/rename β€” e.g. /rename 1 my todo app"; + const result = await callApi(r.ctx, "sessions", "rename", { sessionId, title }); + return "✏️ Renamed to " + esc(result.title) + ""; +} + +export async function cmdFork(r) { + const sessionId = await r.state.resolveSessionId(r.chatId, r.args[0]); + if (!sessionId) return "/fork β€” makes a copy you can experiment on"; + const result = await callApi(r.ctx, "sessions", "fork", { sessionId }); + const newId = result.sessionId; + r.state.chatState(r.chatId).sessionId = newId; + r.state.saveState(); + return "🍴 Forked! New chat: " + newId + "\nIt's now your active chat."; +} + +export async function cmdSearch(r) { + const query = r.rest; + if (!query) return "/search β€” find a past conversation"; + const result = await callApi(r.ctx, "sessions", "search", { query }); + const items = result?.items ?? []; + if (!items.length) return "Nothing found for \"" + esc(query) + "\""; + const lines = items.slice(0, 10).map((it) => "β€’ " + shortId(it.sessionId) + " " + esc(truncate(it.snippet, 140))); + if (result.hasMore) lines.push("…more matches"); + return lines.join("\n"); +} + +export async function cmdExport(r) { + const sessionId = await r.state.resolveSessionId(r.chatId, r.args[0]); + if (!sessionId) return "/export β€” download the full conversation as a file"; + const api = r.ctx.get("apiProxy"); + if (!api?.downloads?.sessionLog) return "Export is unavailable in this deployment."; + await r.state.bot.send(r.chatId, "πŸ“¦ Preparing export…"); + const response = await api.downloads.sessionLog({ rpcId: "exp-" + Math.random().toString(36).slice(2, 8), payload: { sessionId } }); + if (!response || !response.ok) { + const body = await response?.text?.().catch(() => ""); + throw new Error("export failed: " + (body || "unknown")); + } + const buf = Buffer.from(await response.arrayBuffer()); + const filename = "dsh-session-" + sessionId.replace(/^session-/, "").slice(0, 8) + ".zip"; + const path = join(dshHome(), filename); + writeFileSync(path, buf); + await r.state.bot.sendDocument(r.chatId, buf, filename, "πŸ“¦ Full conversation export (" + (buf.length / 1024).toFixed(1) + " KB)"); + return null; +} + +/* ── workspaces ── */ + +export async function cmdWorkspaces(r) { + const result = await callApi(r.ctx, "workspace", "list", {}); + const items = result?.items ?? []; + const archived = result?.archivedSessionIds ?? []; + if (!items.length) return "No workspaces yet β€” /ws new "; + const lines = ["Workspaces β€” folders your chats live in"]; + items.forEach((w, i) => { + const ts = typeof w.updatedAt === "number" ? w.updatedAt : Date.parse(String(w.updatedAt ?? "")); + lines.push((i + 1) + ". " + esc(w.title || w.path) + " β€” " + (w.sessionIds?.length ?? 0) + " chats\n " + esc(w.path) + (isNaN(ts) ? "" : " Β· " + fmtAge(ts))); + }); + if (archived.length) lines.push("\nπŸ—„ " + archived.length + " archived chat(s)"); + lines.push("", "/ws new to add one"); + return truncate(lines.join("\n"), 3000); +} + +export async function cmdWs(r) { + const action = (r.args[0] ?? "new").toLowerCase(); + const rest = r.args.slice(1); + if (action === "new" || action === "create") { + const path = rest.join(" ").trim(); + if (!path) return "/ws new β€” e.g. /ws new C:\\projects\\myapp"; + const result = await callApi(r.ctx, "workspace", "create", { path }); + return "πŸ“ Workspace " + (result.created ? "created" : "already existed") + ": " + esc(result.workspace?.title || result.workspace?.path || path) + ""; + } + const list = await callApi(r.ctx, "workspace", "list", {}); + const items = list?.items ?? []; + if (action === "rename") { + const n = Number(rest[0]); + const title = rest.slice(1).join(" ").trim(); + const ws = items[n - 1]; + if (!ws || !title) return "/ws rename β€” see /workspaces"; + await callApi(r.ctx, "workspace", "rename", { workspaceId: ws.id, title }); + return "✏️ Workspace renamed to " + esc(title) + ""; + } + if (action === "delete" || action === "rm") { + const n = Number(rest[0]); + const ws = items[n - 1]; + if (!ws) return "/ws delete β€” see /workspaces"; + await callApi(r.ctx, "workspace", "delete", { workspaceId: ws.id }); + return "πŸ—‘ Workspace deleted: " + esc(ws.title || ws.path); + } + return "/ws new Β· /ws rename Β· /ws delete <n>"; +} + +export async function cmdMkdir(r) { + const path = r.rest.trim(); + if (!path) return "/mkdir <path> β€” create a folder"; + const parts = path.split(/[\\/]+/).filter(Boolean); + const name = parts.pop(); + const parent = parts.join("\\") || "C:\\"; + await callApi(r.ctx, "host", "createDirectory", { path: parent, name }); + return "πŸ“ Created " + esc(path); +} + +export async function cmdArchive(r) { + const sessionId = await r.state.resolveSessionId(r.chatId, r.args[0]); + if (!sessionId) return "/archive <chat> β€” tuck it away (stays saved)"; + const result = await callApi(r.ctx, "workspace", "archiveSession", { sessionId }); + return "πŸ—„ Archived <code>" + shortId(sessionId) + "</code> β€” " + (result.archivedSessionIds?.length ?? 0) + " archived total."; +} + +/* ── subagents deep ── */ + +export async function cmdInterrupt(r) { + const childId = r.args[0]; + const sessionId = await r.state.resolveSessionId(r.chatId, ""); + if (!childId || !sessionId) return "/interrupt <agentId> β€” see /agents"; + await callApi(r.ctx, "subagents", "interrupt", { parentSessionId: sessionId, childSessionId: childId, mode: "continuable" }); + return "⏹ Interrupted <code>" + shortId(childId) + "</code>"; +} + +export async function cmdAgentLog(r) { + const childId = r.args[0]; + const sessionId = await r.state.resolveSessionId(r.chatId, ""); + let count = 10; + if (/^\d+$/.test(r.args[1] ?? "")) count = Number(r.args[1]); + if (!childId || !sessionId) return "/agentlog <agentId> [n] β€” see /agents"; + const result = await callApi(r.ctx, "subagents", "history", { + parentSessionId: sessionId, + childSessionId: childId, + mode: "continuable", + maxMessages: count, + }); + const events = result?.events ?? []; + const shown = []; + for (const entry of events) { + const ev = entry?.event ?? entry; + if (ev.type === "user/message") { + const t = contentText(ev.data?.content); + if (t) shown.push("πŸ§‘ " + esc(truncate(t, 260))); + } else if (ev.type === "assistant/message") { + const t = contentText(ev.data?.message?.content, { textOnly: true }); + if (t) shown.push("πŸ€– " + esc(truncate(t, 420))); + } else if (ev.type === "tool/call") { + shown.push("πŸ”§ " + esc(ev.data?.name ?? "?")); + } + } + if (!shown.length) return "<code>" + shortId(childId) + "</code> β€” no surface messages"; + return "<b>" + esc(shortId(childId)) + "</b> (last " + Math.min(count, shown.length) + ")\n" + shown.slice(-count).join("\n"); +} + +/* ── presets / skills / plugins ── */ + +export async function cmdPresets(r) { + const result = await callApi(r.ctx, "agentPresets", "list", {}); + const items = result?.items ?? []; + if (!items.length) return "No agent presets available."; + const lines = ["<b>Agent presets</b> β€” /preset <name> switches this chat"]; + items.forEach((p) => { + lines.push("β€’ <code>" + esc(p.id) + "</code>" + (p.isDefault ? " (default)" : "") + (p.name ? " β€” " + esc(p.name) : "") + " [" + p.trust + "]"); + }); + return truncate(lines.join("\n"), 2500); +} + +export async function cmdPreset(r) { + const preset = r.args[0]; + const sessionId = await r.state.resolveSessionId(r.chatId, ""); + if (!preset || !sessionId) return "/preset <name> β€” see /presets"; + const result = await callApi(r.ctx, "agentPresets", "select", { sessionId, agentPreset: preset }); + return "βœ… This chat now uses preset <b>" + esc(result.agentPreset) + "</b>"; +} + +export async function cmdSkills(r) { + const sessionId = await r.state.resolveSessionId(r.chatId, ""); + if (!sessionId) return "Open a chat first, then /skills."; + const result = await callApi(r.ctx, "skills", "list", { sessionId }); + const skills = result?.skills ?? []; + if (!skills.length) return "No skills available for this chat."; + return skills.slice(0, 20).map((s) => "β€’ <code>" + esc(s.name) + "</code>" + (s.description ? " β€” " + esc(truncate(s.description, 90)) : "")).join("\n"); +} + +export async function cmdPlugins(r) { + const gateway = r.ctx.get("typertGateway"); + if (!gateway) return "Unavailable in this deployment."; + const { invoke } = await import("./util.js"); + let result; + try { + result = await invoke(gateway, "pluginInventory", "query", {}); + } catch (error) { + if (error.code !== "invocation-unavailable") throw error; + result = await invoke(gateway, "pluginInventory", "list", {}); + } + const entries = Array.isArray(result) ? result : (result?.entries ?? result?.plugins ?? []); + if (!entries.length) return "No plugin inventory available."; + return entries.slice(0, 25).map((p) => "β€’ <code>" + esc(p.id ?? p.name ?? "?") + "</code>" + (p.disabled ? " (disabled)" : "")).join("\n"); +} + +/* ── settings / credentials / permissions ── */ + +export async function cmdSettings(r) { + const result = await callApi(r.ctx, "settings", "describe", {}); + const namespaces = result?.namespaces ?? []; + if (!namespaces.length) return "No settings sections exposed."; + const lines = ["<b>Settings</b> β€” /setting <ns> <json> to change one"]; + namespaces.forEach((ns) => lines.push("β€’ <code>" + esc(ns.ns) + "</code>")); + return truncate(lines.join("\n"), 2500); +} + +export async function cmdSetting(r) { + const ns = r.args[0]; + const json = r.rest.replace(ns, "").trim(); + if (!ns || !json) return "/setting <ns> <json> β€” e.g. /setting ui-theme {\"preference\":\"light\"}"; + let value; + try { + value = JSON.parse(json); + } catch (error) { + return "That JSON doesn't parse: " + esc(error.message); + } + await callApi(r.ctx, "settings", "update", { ns, patch: value }); + return "βœ… <code>" + esc(ns) + "</code> updated."; +} + +export async function cmdCreds(r) { + const [action, ref, value] = r.args; + if (action === "set" && ref && value) { + await callApi(r.ctx, "credentials", "set", { ref, value }); + return "βœ… <code>" + esc(ref) + "</code> saved."; + } + if (action === "unset" && ref) { + await callApi(r.ctx, "credentials", "unset", { ref }); + return "πŸ—‘ <code>" + esc(ref) + "</code> removed."; + } + const result = await callApi(r.ctx, "credentials", "describe", { + refs: ["DEEPSEEK_API_KEY", "OPENCODE_GO_API_KEY"], + }); + const creds = result?.credentials ?? {}; + const lines = ["<b>Credentials</b> (configured? where from)"]; + for (const [refName, view] of Object.entries(creds)) { + lines.push("β€’ <code>" + esc(refName) + "</code> " + (view.configured ? "βœ…" : "❌") + (view.source ? " (" + esc(view.source) + ")" : "")); + } + lines.push("", "/creds set <NAME> <value> Β· /creds unset <NAME>"); + return lines.join("\n"); +} + +export async function cmdPermission(r) { + const sessionId = await r.state.resolveSessionId(r.chatId, ""); + const modeArg = (r.args[0] ?? "").toLowerCase(); + const modes = { + "read-only": "read-only", ro: "read-only", read: "read-only", + "workspace-write": "workspace-write", write: "workspace-write", workspace: "workspace-write", + "danger-full-access": "danger-full-access", full: "danger-full-access", danger: "danger-full-access", + }; + const mode = modes[modeArg]; + if (!sessionId) return "Open a chat first."; + if (!mode) return "/permission read|write|full β€” how much of your files this chat's AI can touch"; + const session = r.ctx.get("sessions")?.get(sessionId); + if (!session || typeof session.append !== "function") return "This chat isn't attached right now."; + session.append("sandbox/mode", { mode }); + return "βœ… This chat's AI access: <b>" + mode + "</b>"; +} diff --git a/packages/telegram-remote/lib/index.js b/packages/telegram-remote/lib/index.js new file mode 100644 index 0000000..36da12d --- /dev/null +++ b/packages/telegram-remote/lib/index.js @@ -0,0 +1,663 @@ +/** + * dsh-telegram-remote β€” full remote control and state visibility for the + * DeepSeek Harness from Telegram mobile. Runs inside the dsh process as a + * Cordis host plugin; talks to the Telegram Bot API by long polling. + */ + +import { appendFileSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { TelegramBot, esc } from "./bot.js"; +import { dshHome, nowIso, truncate, shortId, contentText, markdownToHtml, callApi } from "./util.js"; +import { PluginState } from "./state.js"; +import { COMMAND_TABLE, KEYBOARD, WELCOME, COMMANDS_MENU, cmdMsg } from "./commands.js"; +import { setupStreaming, handleInteractiveCallback } from "./stream.js"; +import { transcribeAudio } from "./whisper.js"; + +export const name = "telegram-remote"; + +// No Config schema on purpose: the plugin must stay dependency-free (it is +// loaded through a pnpm link whose own node_modules are not installed), so +// all configuration arrives as plain values in the loader entry's config +// object and defaults are applied in code. + +export const inject = ["timer", "sessions", "agents", "jobs", "fs", "sessionQuery", "typertGateway", "goals", "settings", "shell"]; + +// Retry cadence for the live mux stream when apiProxy.events.mux is not +// registered yet at apply time (cold-start race). +const STREAM_RETRY_INTERVAL_MS = 5000; + +/* ── event forwarding (push notifications) ── */ + + +/* ── background job + session lifecycle notifications ── */ + +function setupJobsForwarding(state) { + const ctx = state.ctx; + const jobs = ctx.get("jobs"); + if (jobs && typeof jobs.onJobsChanged === "function") { + try { + jobs.onJobsChanged((owner) => { + try { + const snapshots = owner ? jobs.list(owner) : []; + for (const job of snapshots) { + const previous = state.jobStatuses.get(job.id); + if (previous !== undefined && previous !== job.status) { + const icons = { running: "πŸƒ", stopping: "⏳", completed: "βœ…", killed: "⏹", failed: "❌" }; + const ownerName = owner?.session?.id ? shortId(owner.session.id) : "?"; + let line = "<b>job " + esc(job.id) + "</b> [" + esc(job.label) + "] " + (icons[job.status] ?? job.status); + if (job.detail) line += " β€” " + esc(job.detail); + line += " (owner " + ownerName + ")"; + state.notifyAll(line); + } + state.noteJobStatus(job.id, job.status); + } + } catch (error) { + state.log("jobs changed forward failed: " + error.message); + } + }); + } catch (error) { + state.log("jobs.onJobsChanged failed: " + error.message); + } + } + ctx.on("session/created", (session) => { + try { state.notifyAll("πŸ†• session created: <code>" + (session?.id ?? "?") + "</code>"); } catch {} + }); + ctx.on("session/disposed", (session) => { + try { state.notifyAll("πŸ—‘ session disposed: <code>" + (session?.id ?? "?") + "</code>"); } catch {} + }); +} + + +/* ── update dispatch ── */ + +async function handleText(state, chatId, userId, text, messageId) { + const trimmed = String(text ?? "").trim(); + if (!trimmed) return; + const tokens = trimmed.split(/\s+/); + const first = tokens[0].toLowerCase(); + const command = first.startsWith("/") ? first.slice(1) : first; + const handler = COMMAND_TABLE[command]; + const args = tokens.slice(1); + const rest = trimmed.slice(first.length).trim(); + + const authorized = userId != null && state.isAuthorized(userId); + if (!authorized) { + try { + await state.bot.send( + chatId, + "β›” <b>Unauthorized</b>\nYour telegram user id: <code>" + (userId ?? "unknown") + "</code>\nAdd it to <code>allowedUserIds</code> (or set <code>ownerChatId</code>) in the plugin config.", + ); + } catch {} + state.log("unauthorized contact: user=" + userId + " chat=" + chatId + " text=" + truncate(trimmed, 80)); + return; + } + + if (messageId != null) state.chatState(chatId).lastUserMessageId = messageId; + + const runtime = { + ctx: state.ctx, + state, + bot: state.bot, + chatId, + userId, + authorized, + args, + rest, + text: trimmed, + }; + + // ── dispatch: slash commands, friendly shortcuts, or a chat message ── + let fn = null; + if (trimmed.startsWith("/")) { + fn = handler ?? null; + if (!fn) { + try { + await state.bot.send(chatId, "Hmm, I don't know <code>" + esc(first) + "</code> β€” /help shows what I can do πŸ™‚"); + } catch {} + return; + } + } else { + const lower = trimmed.toLowerCase().replace(/[.!?]+$/, ""); + if (lower === "help" || lower === "what can you do") fn = COMMAND_TABLE.help; + else if (lower === "status" || lower === "what's up" || lower === "ping") fn = COMMAND_TABLE.status; + else if (lower === "models" || lower === "model" || lower === "change model" || lower === "which models") fn = COMMAND_TABLE.models; + else if (lower === "chats" || lower === "my chats" || lower === "sessions" || lower === "chat list") fn = COMMAND_TABLE.chats; + else if (lower === "new chat" || lower === "start chat" || lower === "new") fn = COMMAND_TABLE.new; + else if (lower === "stop" || lower === "stop that" || lower === "cancel") fn = COMMAND_TABLE.stop; + else if (lower === "hi" || lower === "hello" || lower === "hey" || lower === "yo") { + // Greeting: welcome them, don't waste an agent turn. + const cs = state.chatState(chatId); + if (!cs.sessionId) { + try { await state.bot.send(chatId, WELCOME, { replyMarkup: KEYBOARD }); } catch {} + return; + } + fn = cmdMsg; + } else if (/^\d+$/.test(lower)) { + const cs = state.chatState(chatId); + const modelsFresh = Array.isArray(cs.lastModels) && cs.lastModels.length > 0 && Date.now() - (cs.lastModelsAt ?? 0) < 10 * 60_000; + const chatsFresh = Array.isArray(cs.sessionIds) && cs.sessionIds.length > 0 && Date.now() - (cs.lastListAt ?? 0) < 10 * 60_000; + if (modelsFresh && (!chatsFresh || (cs.lastModelsAt ?? 0) >= (cs.lastListAt ?? 0))) { + fn = COMMAND_TABLE.model; + runtime.args = [lower]; + runtime.rest = lower; + } else if (chatsFresh) { + fn = COMMAND_TABLE.open; + runtime.args = [lower]; + runtime.rest = lower; + } else { + fn = cmdMsg; + } + } else { + fn = cmdMsg; + } + } + try { + const reply = await fn(runtime); + if (reply != null) { + const isObj = typeof reply === "object" && reply !== null && !Array.isArray(reply); + const msg = isObj ? reply.text : reply; + const kb = isObj && reply.keyboard ? reply.keyboard : KEYBOARD; + if (msg != null && String(msg).trim().length > 0) { + await state.bot.send(chatId, msg, { replyMarkup: kb }); + } + } + state.log("cmd " + command + " by " + userId + " (chat " + chatId + ")"); + } catch (error) { + state.log("cmd " + command + " failed: " + (error.stack ?? error.message)); + let friendly = String(error.message ?? error).slice(0, 200); + // Translate common errors into plain language. + if (/session.*not found|not found.*session/i.test(friendly)) { + friendly = "That chat isn't available right now β€” try /chats and pick one."; + } else if (/no active session|no api surface/i.test(friendly)) { + friendly = "No chat is open yet β€” tap πŸ’¬ New chat and say hi!"; + } else if (/unauthorized|not authorized/i.test(friendly)) { + friendly = "You're not allowed to do that."; + } else if (/agent-busy|busy/i.test(friendly)) { + friendly = "The AI is busy right now β€” try again in a moment."; + } + try { + await state.bot.send(chatId, "⚠️ " + friendly + "\n<i>If you keep seeing this, /help or ask the owner.</i>"); + } catch {} + } +} + +/* ── apply ── */ + +export function apply(ctx, config) { + let state = null; + try { + const token = resolveToken(config); + if (!token) { + logBoot(ctx, "no bot token β€” set config.botToken, tokenEnv, or tokenFile"); + return () => {}; + } + const bot = new TelegramBot(token, (msg) => { + try { appendFileSync(join(dshHome(), "telegram-remote.log"), "[" + nowIso() + "] " + msg + "\n", "utf8"); } catch {} + }); + state = new PluginState(ctx, config, bot); + bot.onOffset = () => { try { state.saveState(); } catch {} }; + state.loadState(); + // Newest-state pointer for the process-wide watchdog (survives reloads). + globalThis.__dshTgState = state; + + // ── live-reload safety: stop every earlier poller ── + // HMR hot-reloads this plugin by re-running apply in the SAME process. + // globalThis survives module re-imports, so a stale poller from the old + // module instance is still reachable here β€” stop it before starting a new + // one, otherwise Telegram returns 409 conflicts forever (two getUpdates + // loops). This also covers reloads where cordis never runs our dispose. + const botRegistry = (globalThis.__dshTelegramBots ??= []); + for (const older of botRegistry) { + if (older === bot) continue; + try { + // Carry the polling cursor forward: without this, the reloaded bot + // starts at offset 0 with an empty seen-set and Telegram RE-DELIVERS + // recent updates β€” which is why old messages you sent kept appearing + // again after every hot reload. + if (older.offset > bot.offset) bot.offset = older.offset; + for (const id of older.seen) bot.seen.add(id); + older.stop(); + state.log("stopped previous poller (live reload)"); + } catch {} + } + botRegistry.push(bot); + // Bound the registry: only the newest bot (and its immediate predecessor, + // which may still be finishing its final getUpdates) needs to stay alive. + while (botRegistry.length > 3) botRegistry.shift(); + + // ── single-instance lock ── + // Only ONE harness instance may poll the bot token (Telegram 409s a + // second poller). Extra instances stay dormant and take over when the + // lock-holder exits. + const lockPath = join(dshHome(), "telegram-remote.lock"); + let holdLock = false; + const isPidAlive = (pid) => { + if (!pid) return false; + try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; } + }; + const tryAcquire = () => { + try { + const raw = readFileSync(lockPath, "utf8").trim(); + const other = Number(raw.split(/\s+/)[0]); + if (other && other !== process.pid && isPidAlive(other)) return false; + } catch {} + try { writeFileSync(lockPath, process.pid + " " + nowIso() + "\n"); holdLock = true; } catch {} + return holdLock; + }; + const startBot = () => { + // Inherit identity from a previous instance when possible: HMR reloads + // re-run apply, and getMe is an extra Telegram round-trip we can skip. + const inherited = (globalThis.__dshTelegramBots ?? []) + .filter((b) => b !== bot && b.me) + .map((b) => b.me) + .pop(); + const ready = inherited + ? Promise.resolve({ ...inherited }) + : bot.getMe(); + ready.then(async (me) => { + bot.me = me; + state.log("bot online: @" + me.username + " (id " + me.id + ")"); + try { + const menuKey = JSON.stringify(COMMANDS_MENU); + if (globalThis.__dshTgMenuKey !== menuKey) { + await bot.setMyCommands(COMMANDS_MENU); + globalThis.__dshTgMenuKey = menuKey; + state.log("command menu published (" + COMMANDS_MENU.length + " shortcuts)"); + } + } catch (error) { + state.log("setMyCommands failed: " + error.message); + } + setupDispatch(state); + setupJobsForwarding(state); + // Always refresh the stream engine on reload: setupStreaming publishes + // a LIVE handler reference into globalThis (one mux subscription total) + // and replaces it with the newest code β€” so fixes actually reach the + // running bot instead of freezing the first-loaded version. When the + // mux stream is not available yet (cold start), keep retrying every 5s + // until it appears. + setupStreamingWithRetry(state); + if (config.notifyOnStartup && !(globalThis.__dshTgStarted ?? false)) { + // Only announce once per process β€” HMR reloads re-run apply and must + // NOT spam the chat with a fresh "online" message every time. + globalThis.__dshTgStarted = true; + const targets = new Set([...(config.allowedUserIds ?? []), ...(config.ownerChatId != null ? [config.ownerChatId] : [])]); + for (const chatId of targets) { + state.push(chatId, "🟒 <b>DSH Remote online</b>\n─────── β‹†β‹…β˜†β‹…β‹† ───────\n<i>pid " + process.pid + " Β· " + esc(process.cwd()) + "\n/help for everything</i>"); + } + } + }).catch((error) => { + state.log("getMe failed: " + error.message); + setupDispatch(state); + }); + }; + + state.holdLockRef = () => holdLock; + state.tryAcquireRef = tryAcquire; + state.startBotRef = startBot; + + if (tryAcquire()) { + state.log("bot lock acquired (pid " + process.pid + ")"); + startBot(); + } else { + state.log("another instance holds the bot lock β€” dormant until it releases"); + } + + // Single watchdog per process: HMR reloads re-run apply, and each new + // instance would otherwise stack another 30s interval forever. Each tick + // resolves the CURRENT (newest) bot from the registry so the watchdog + // keeps working across reloads. + if (!(globalThis.__dshTgWatchdog ?? false)) { + globalThis.__dshTgWatchdog = true; + ctx.setInterval(() => { + try { + const st = globalThis.__dshTgState; + if (!st) return; + const current = (globalThis.__dshTelegramBots ?? []).at(-1); + if (!current) { + // No poller alive: try to claim the single-instance lock. + if (!st.holdLockRef && st.tryAcquireRef && st.tryAcquireRef()) { + st.log("bot lock acquired after retry"); + st.startBotRef?.(); + } + return; + } + if (current.stopped) return; + if (current.lastPollAt > 0 && Date.now() - current.lastPollAt > 180_000 && !current.loop) { + st.log("watchdog: restarting poll loop"); + st.startBotRef?.(); + } + } catch {} + }, 30_000); + } + + state.log("plugin loaded: pid " + process.pid + " cwd " + process.cwd() + " workspace " + config.workspaceRoot); + + return () => { + try { state.bot.stop(); } catch {} + try { + const i = (globalThis.__dshTelegramBots ?? []).indexOf(bot); + if (i >= 0) globalThis.__dshTelegramBots.splice(i, 1); + } catch {} + try { if (watchdog) ctx.clearInterval(watchdog); } catch {} + try { + if (holdLock) { + const raw = readFileSync(lockPath, "utf8").trim(); + if (Number(raw.split(/\s+/)[0]) === process.pid) unlinkSync(lockPath); + } + } catch {} + try { state.flushState(); } catch {} + state.log("plugin disposed"); + }; + } catch (error) { + logBoot(ctx, "plugin failed to start: " + (error.stack ?? error.message)); + return () => {}; + } +} + +/** + * Start the live mux stream, retrying every STREAM_RETRY_INTERVAL_MS until + * apiProxy.events.mux becomes available. Safe to call on every apply: HMR + * reloads re-run apply, so any interval from an earlier apply is disposed + * first β€” at most one retry loop can exist per process. The interval is + * registered on ctx.timer and is therefore also disposed with the context. + * @param {PluginState} state - current plugin state. + */ +function setupStreamingWithRetry(state) { + const previous = globalThis.__dshTgStreamRetry; + globalThis.__dshTgStreamRetry = null; + if (previous) { + try { previous(); } catch {} + } + if (setupStreaming(state)) return; + state.log("streaming unavailable β€” retrying every " + STREAM_RETRY_INTERVAL_MS + "ms"); + globalThis.__dshTgStreamRetry = state.ctx.timer.interval(() => { + if (setupStreaming(state)) { + const dispose = globalThis.__dshTgStreamRetry; + globalThis.__dshTgStreamRetry = null; + if (dispose) { + try { dispose(); } catch {} + } + state.log("streaming connected"); + } + }, STREAM_RETRY_INTERVAL_MS); +} + +function setupDispatch(state) { + const bot = state.bot; + bot.start(async (update) => { + const message = update.message; + if (message && message.text) { + await handleText(state, message.chat.id, message.from?.id, message.text, message.message_id); + return; + } + if (message && message.photo?.length > 0) { + // A photo with an optional caption becomes a message WITH the image. + const largest = message.photo[message.photo.length - 1]; + await handlePhoto(state, message.chat.id, message.from?.id, largest.file_id, message.caption ?? "", message.message_id); + return; + } + if (message && (message.voice || message.audio || (message.document && /^audio\//i.test(message.document.mime_type ?? "")))) { + const audio = message.voice ?? message.audio ?? message.document; + await handleAudio(state, message.chat.id, message.from?.id, audio, message.message_id); + return; + } + const callback = update.callback_query; + if (callback && callback.data) { + const chatId = callback.message?.chat?.id; + if (chatId == null) { + await bot.answerCallbackQuery(callback.id, ""); + return; + } + // Interactive keyboards (questions, approvals) route here first. + const handled = handleInteractiveCallback(state, chatId, callback.data); + const ack = typeof handled === "string" ? handled : ""; + await bot.answerCallbackQuery(callback.id, ack); + if (!handled) { + const data = callback.data.startsWith("md:") ? "/model " + callback.data.slice(3) : callback.data; + await handleText(state, chatId, callback.from?.id, data, null); + } + } + }); +} + +async function handlePhoto(state, chatId, userId, fileId, caption, messageId) { + try { + const authorized = userId != null && state.isAuthorized(userId); + if (!authorized) { + try { + await state.bot.send(chatId, "β›” <b>Unauthorized</b>\nYour telegram user id: <code>" + (userId ?? "unknown") + "</code>"); + } catch {} + return; + } + const file = await state.bot.getFile(fileId); + const bytes = await state.bot.downloadFile(file.file_path); + const mediaType = /.png$/i.test(file.file_path ?? "") ? "image/png" : /.webp$/i.test(file.file_path ?? "") ? "image/webp" : "image/jpeg"; + const cs = state.chatState(chatId); + if (messageId != null) cs.lastUserMessageId = messageId; + if (!cs.sessionId) { + try { + const created = await callApi(state.ctx, "sessions", "create", { cwd: state.config.workspaceRoot || process.cwd() }); + cs.sessionId = created.sessionId; + state.saveState(); + } catch {} + } + if (!cs.sessionId) { + await state.bot.send(chatId, "No chat is open yet β€” tap πŸ’¬ New chat first."); + return; + } + const text = caption || "see the attached image"; + const content = [{ type: "text", text }, { type: "image", mediaType, data: bytes.toString("base64") }]; + + // Capability gate: the active model may be text-only (deepseek-v4-flash + // etc.). Detect that BEFORE prompting, so the user gets a clear message + // instead of a red "UNSUPPORTED_CONTENT" turn failure. + const modelInfo = await currentModelModalities(state, cs.sessionId); + if (!modelInfo.imageSupported) { + const modelLabel = modelInfo.label ? " (<code>" + esc(modelInfo.label) + "</code>)" : ""; + const vision = modelInfo.visionSuggestions?.length + ? "\nπŸ“· Vision models you can switch to: <code>" + esc(modelInfo.visionSuggestions.join("</code> Β· <code>")) + "</code>\nSwitch with <code>/model <id></code>" + : ""; + await state.bot.send( + chatId, + "πŸ“· <b>This model can't see images yet</b>" + modelLabel + "\n" + + "Send your question as text instead, or switch to a vision-capable model first." + vision, + ); + state.log("photo rejected: model " + (modelInfo.label ?? "?") + " has no image support"); + return; + } + + await callApi(state.ctx, "sessions", "prompt", { + sessionId: cs.sessionId, + mode: "queue", + content, + clientTimeZone: (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return "UTC"; } })(), + }); + state.notePrompt(cs.sessionId, text); + state.log("photo sent to " + cs.sessionId + " by " + userId); + } catch (error) { + state.log("photo handling failed: " + (error.stack ?? error.message)); + try { + await state.bot.send(chatId, "⚠️ Couldn't send the photo: " + esc(error.message)); + } catch {} + } +} + +const AUDIO_EXT = { + "audio/ogg": "ogg", + "audio/mpeg": "mp3", + "audio/mp3": "mp3", + "audio/wav": "wav", + "audio/x-wav": "wav", + "audio/mp4": "m4a", + "audio/aac": "m4a", + "audio/x-m4a": "m4a", + "audio/flac": "flac", +}; + +/** Prefer Telegram's original file name; else derive one from the mime type. */ +function pickAudioFilename(audio, mimeType) { + const name = String(audio.file_name ?? "").trim(); + if (name) return name; + if (mimeType && /^audio\/ogg/i.test(String(mimeType).split(";")[0].trim())) return "voice.ogg"; + const ext = AUDIO_EXT[String(mimeType ?? "").split(";")[0].trim().toLowerCase()] ?? "bin"; + return "audio." + ext; +} + +/** + * Transcribe a voice message / audio file / audio document via the home-lab + * Whishper server, show the transcript, and queue it into the chat session + * like a normal user message. + */ +async function handleAudio(state, chatId, userId, audio, messageId) { + let statusMessageId = null; + try { + const authorized = userId != null && state.isAuthorized(userId); + if (!authorized) { + try { + await state.bot.send(chatId, "β›” <b>Unauthorized</b>\nYour telegram user id: <code>" + (userId ?? "unknown") + "</code>"); + } catch {} + return; + } + const statusIds = await state.bot.send(chatId, "πŸŽ™οΈ Transcribing…"); + statusMessageId = statusIds[0]; + + const file = await state.bot.getFile(audio.file_id); + const bytes = await state.bot.downloadFile(file.file_path); + const mimeType = audio.mime_type ?? (audio.voice ? "audio/ogg" : "application/octet-stream"); + const filename = pickAudioFilename(audio, mimeType); + + const text = await transcribeAudio( + { + baseUrl: state.config.whisperBaseUrl, + model: state.config.whisperModel, + device: state.config.whisperDevice, + language: state.config.whisperLanguage, + timeoutMs: state.config.whisperTimeoutMs, + maxBytes: state.config.whisperMaxBytes, + }, + bytes, + filename, + mimeType, + ); + const trimmed = String(text ?? "").trim(); + if (!trimmed) throw new Error("Whisper returned an empty transcript"); + + // The transcription itself succeeded β€” a failed edit must not report an + // error for a successful transcription. + try { + await state.bot.editMessageText(chatId, statusMessageId, "πŸŽ™οΈ " + esc(truncate(trimmed, 3500))); + } catch (editError) { + state.log("audio transcript edit failed: " + editError.message); + } + + const cs = state.chatState(chatId); + if (messageId != null) cs.lastUserMessageId = messageId; + if (!cs.sessionId) { + try { + const created = await callApi(state.ctx, "sessions", "create", { cwd: state.config.workspaceRoot || process.cwd() }); + cs.sessionId = created.sessionId; + state.saveState(); + } catch {} + } + if (!cs.sessionId) { + await state.bot.send(chatId, "No chat is open yet β€” tap πŸ’¬ New chat first."); + return; + } + await callApi(state.ctx, "sessions", "prompt", { + sessionId: cs.sessionId, + mode: "queue", + content: [{ type: "text", text: trimmed }], + clientTimeZone: (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return "UTC"; } })(), + }); + state.notePrompt(cs.sessionId, trimmed); + state.log("audio transcribed (" + state.config.whisperModel + ") and sent to " + cs.sessionId + " by " + userId); + } catch (error) { + state.log("audio handling failed: " + (error.stack ?? error.message)); + const message = "⚠️ Couldn't transcribe the audio: " + esc(error.message); + try { + if (statusMessageId == null) throw new Error("no status message"); + await state.bot.editMessageText(chatId, statusMessageId, message); + } catch { + try { await state.bot.send(chatId, message); } catch {} + } + } +} + +/** + * Resolve the ACTIVE model for a session and whether it supports image input. + * The llm.models catalog does not carry inputModalities, so the decision asks + * the LLM runtime directly β€” the same bridge-aware resolution the harness + * admission uses: dsh-vision-bridge reports bridged models (e.g. deepseek-*) + * as image-capable, because their images are converted to text before + * dispatch. Only same-provider vision models are offered as switch targets. + */ +async function currentModelModalities(state, sessionId) { + const ctx = state.ctx; + let current = {}; + try { + const r = await callApi(ctx, "sessions", "models", { sessionId }); + current = r?.current ?? {}; + } catch {} + const def = ctx.get("agentDefaultModel")?.currentSelection?.() ?? {}; + const provider = current.provider ?? def.provider ?? ""; + const modelId = current.model ?? def.model ?? ""; + const label = (provider ? provider + "/" : "") + modelId; + let imageSupported = false; + let visionSuggestions = []; + const llm = ctx.get("llm"); + if (llm && typeof llm.resolveModelInfo === "function") { + try { + const info = await llm.resolveModelInfo(provider, modelId); + imageSupported = Array.isArray(info?.inputModalities) && info.inputModalities.includes("image"); + } catch {} + } + if (!imageSupported) { + try { + const catalog = await callApi(ctx, "llm", "models", {}); + const groups = Array.isArray(catalog) ? catalog : (catalog?.groups ?? []); + for (const group of groups) { + const groupId = group.group?.id ?? group.id ?? ""; + if (groupId !== provider) continue; + const models = group.group?.models ?? group.models ?? []; + for (const model of models) { + if ((model.id ?? "") === modelId) continue; + try { + const info = await llm.resolveModelInfo(groupId, model.id); + if (Array.isArray(info?.inputModalities) && info.inputModalities.includes("image")) { + visionSuggestions.push(model.id); + if (visionSuggestions.length >= 4) break; + } + } catch {} + } + break; + } + } catch {} + } + return { imageSupported, label, visionSuggestions }; +} + +function resolveToken(config) { + if (config.botToken) return config.botToken; + if (config.tokenEnv) { + const value = process.env[config.tokenEnv]; + if (value) return value; + } + if (config.tokenFile) { + try { + const value = readFileSync(config.tokenFile, "utf8").trim(); + if (value) return value; + } catch {} + } + try { + const value = readFileSync(join(dshHome(), "telegram-remote.token"), "utf8").trim(); + if (value) return value; + } catch {} + return ""; +} + +function logBoot(ctx, message) { + try { ctx.logger.warn("[telegram-remote] " + message); } catch {} + try { appendFileSync(join(dshHome(), "telegram-remote.log"), "[" + nowIso() + "] " + message + "\n", "utf8"); } catch {} +} + +export default { name, inject, apply }; diff --git a/packages/telegram-remote/lib/state.js b/packages/telegram-remote/lib/state.js new file mode 100644 index 0000000..73a831f --- /dev/null +++ b/packages/telegram-remote/lib/state.js @@ -0,0 +1,327 @@ +/** + * PluginState for dsh-telegram-remote: per-chat state, logging, push queue, + * prompt-echo suppression. + */ + +import { appendFile, rename, stat } from "node:fs/promises"; +import { readFileSync, writeFileSync, renameSync } from "node:fs"; +import { join } from "node:path"; +import { dshHome, nowIso, sleep, shortId } from "./util.js"; + +const LOG_MAX_BYTES = 2 * 1024 * 1024; +const LOG_ROTATE_EVERY = 64; // check size only every N writes +const STATE_DEBOUNCE_MS = 250; + +export class PluginState { + ctx; + config; + bot; + logPath; + statePath; + chats = new Map(); + recentPrompts = new Map(); + jobStatuses = new Map(); + pendingSends = new Map(); + pendingOpts = new Map(); + sendingChats = new Set(); + restartScriptPath; + + constructor(ctx, config, bot) { + this.ctx = ctx; + // Apply code-side defaults (the plugin deliberately ships no Config + // schema, so loader entries may omit any key). + this.config = { + botToken: "", + tokenEnv: "TELEGRAM_BOT_TOKEN", + tokenFile: "", + allowedUserIds: [], + ownerChatId: undefined, + workspaceRoot: process.cwd(), + defaultSessionId: "", + allowEval: true, + notifyOnStartup: true, + stateFile: "", + logFile: "", + pollTimeoutSec: 50, + maxOutputBytes: 120000, + whisperBaseUrl: "http://192.168.31.159:8082", + whisperModel: "large-v2", + whisperDevice: "cuda", + whisperLanguage: "auto", + whisperTimeoutMs: 120000, + whisperMaxBytes: 20971520, + ...(config ?? {}), + }; + this.bot = bot; + this.logPath = config.logFile || join(dshHome(), "telegram-remote.log"); + this.statePath = config.stateFile || join(dshHome(), "telegram-remote-state.json"); + this.restartScriptPath = join(dshHome(), "telegram-remote-restart.ps1"); + // Batched async log writer: log() is fire-and-forget and never blocks + // the event loop (the old appendFileSync froze polling on every line). + // Lines accumulate and flush in one appendFile per microtask drain. + this._logBuffer = []; + this._logFlushing = false; + this._logWrites = 0; + this._stateTimer = null; + this._stateDirty = false; + this.loadState(); + } + + log(message) { + this._logBuffer.push("[" + nowIso() + "] " + message); + try { this.ctx.logger.info("[telegram-remote] " + message); } catch {} + if (!this._logFlushing) { + this._logFlushing = true; + queueMicrotask(() => { + const lines = this._logBuffer.splice(0); + this._logFlushing = false; + this._writeLog(lines); + }); + } + } + + /** Append a batch of lines; rotate the file lazily every N batches. */ + async _writeLog(lines) { + if (lines.length === 0) return; + const path = this.logPath; + try { + await appendFile(path, lines.join("\n") + "\n", "utf8"); + if (++this._logWrites % LOG_ROTATE_EVERY === 0) { + try { + const size = (await stat(path)).size; + if (size > LOG_MAX_BYTES) await rename(path, path + ".1"); + } catch {} + } + } catch {} + } + + loadState() { + try { + const raw = JSON.parse(readFileSync(this.statePath, "utf8")); + for (const [chatId, value] of Object.entries(raw.chats ?? {})) { + this.chats.set(Number(chatId), { + sessionId: value.sessionId ?? "", + notify: value.notify ?? "off", + }); + } + if (typeof raw.botOffset === "number" && raw.botOffset > this.bot.offset) { + this.bot.offset = raw.botOffset; + } + } catch {} + } + + /** Schedule a state write; coalesces bursts (one write per 250ms window). */ + saveState() { + this._stateDirty = true; + if (this._stateTimer) return; + this._stateTimer = setTimeout(() => { + this._stateTimer = null; + if (this._stateDirty) { + this._stateDirty = false; + this.writeState(); + } + }, STATE_DEBOUNCE_MS); + } + + /** Write the state file immediately (reboot/shutdown/dispose paths). */ + flushState() { + if (this._stateTimer) { + clearTimeout(this._stateTimer); + this._stateTimer = null; + } + this._stateDirty = false; + this.writeState(); + } + + writeState() { + const payload = { + chats: Object.fromEntries([...this.chats.entries()].map(([id, v]) => [String(id), v])), + botOffset: this.bot?.offset ?? 0, + }; + try { + // Atomic: write a sibling temp file then rename, so a crash mid-write + // never leaves a truncated state file behind. + const tmp = this.statePath + ".tmp"; + writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf8"); + try { renameSync(tmp, this.statePath); } catch { writeFileSync(this.statePath, JSON.stringify(payload, null, 2), "utf8"); } + } catch (error) { + this.log("state save failed: " + error.message); + } + } + + chatState(chatId) { + let state = this.chats.get(chatId); + if (!state) { + state = { sessionId: this.config.defaultSessionId || "", notify: "session", sessionIds: [], lastListAt: 0 }; + this.chats.set(chatId, state); + } + return state; + } + + isAuthorized(userId) { + if (this.config.ownerChatId != null && userId === this.config.ownerChatId) return true; + return Array.isArray(this.config.allowedUserIds) && this.config.allowedUserIds.includes(userId); + } + + async listSessions() { + const { callApi } = await import("./util.js"); + try { + const result = await callApi(this.ctx, "sessions", "list", {}); + const items = Array.isArray(result) ? result : (result?.items ?? []); + if (items.length > 0) return items; + } catch {} + // Fallback for profiles without the web api-proxy: read the live/corpus + // registries directly. + const items = []; + const sessions = this.ctx.get("sessions"); + const sessionQuery = this.ctx.get("sessionQuery"); + const seen = new Set(); + if (sessions) { + for (const session of sessions.list()) { + seen.add(session.id); + items.push({ + sessionId: session.id, + updatedAt: session.header?.updatedAt ?? session.header?.createdAt ?? 0, + running: this.ctx.get("agents")?.get(session.id)?.status === "running", + blank: !session.events.some((event) => event.type === "turn/start"), + cwd: session.header?.cwd, + origin: session.header?.origin, + }); + } + } + if (sessionQuery) { + try { + for (const record of await sessionQuery.listSessions()) { + if (seen.has(record.header.id)) continue; + items.push({ + sessionId: record.header.id, + updatedAt: record.header.createdAt ?? 0, + running: false, + blank: true, + cwd: record.header.cwd, + origin: record.header.origin, + }); + } + } catch {} + } + items.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + return items; + } + + /** Resolve a session id argument: full id, prefix, "last", or the chat's active session. */ + async resolveSessionId(chatId, arg) { + const wanted = String(arg ?? "").trim(); + let sessionId = wanted; + if (!sessionId) { + sessionId = this.chatState(chatId).sessionId; + if (!sessionId) return null; + return sessionId; + } + if (sessionId === "last") { + const summaries = await this.listSessions(); + if (!summaries || summaries.length === 0) return null; + return summaries[0].sessionId; + } + const summaries = await this.listSessions(); + if (summaries) { + const match = summaries.find((item) => item.sessionId === sessionId); + const prefix = summaries.find((item) => item.sessionId.startsWith(sessionId)); + return (match ?? prefix ?? null)?.sessionId ?? sessionId; + } + return sessionId; + } + + notePrompt(sessionId, text) { + const list = this.recentPrompts.get(sessionId) ?? []; + list.push({ text: String(text).trim().slice(0, 200), at: Date.now() }); + while (list.length > 20) list.shift(); + this.recentPrompts.set(sessionId, list); + // Bounded memory: drop sessions whose prompts all expired long ago. + if (this.recentPrompts.size > 64) { + const now = Date.now(); + for (const [sid, entries] of this.recentPrompts) { + const fresh = entries.filter((entry) => now - entry.at < 600_000); + if (fresh.length === 0) this.recentPrompts.delete(sid); + else this.recentPrompts.set(sid, fresh); + } + } + } + + isRecentPrompt(sessionId, text) { + const list = this.recentPrompts.get(sessionId) ?? []; + const needle = String(text).trim().slice(0, 200); + const now = Date.now(); + return list.some((entry) => now - entry.at < 90_000 && entry.text === needle); + } + + /** Record a job status transition; keeps only the most recent entries. */ + noteJobStatus(jobId, status) { + this.jobStatuses.set(jobId, status); + if (this.jobStatuses.size > 256) { + const oldest = this.jobStatuses.keys().next().value; + this.jobStatuses.delete(oldest); + } + } + + async #drain(chatId) { + if (this.sendingChats.has(chatId)) return; + this.sendingChats.add(chatId); + try { + for (;;) { + const text = this.pendingSends.get(chatId); + if (text === undefined) break; + this.pendingSends.delete(chatId); + const opts = this.pendingOpts.get(chatId) ?? {}; + this.pendingOpts.delete(chatId); + try { + await this.bot.send(chatId, text, opts.replyTo ? { replyToMessageId: opts.replyTo } : {}); + } catch (error) { + this.log("push to chat " + chatId + " failed: " + error.message); + const again = this.pendingSends.get(chatId); + this.pendingSends.set(chatId, (again ? again + "\n\n" : "") + text); + this.pendingOpts.set(chatId, opts); + await sleep(5000); + break; + } + } + } finally { + this.sendingChats.delete(chatId); + } + } + + /** + * Push a session event to interested chats. + * @param category - "reply" (assistant replies, turn ends: delivered to + * every chat with notify session/all) or "detail" (tool calls, web-side + * user messages, errors: only notify=all chats β€” too noisy otherwise). + */ + notifySession(sessionId, text, category = "reply") { + for (const [chatId, state] of this.chats) { + if (state.notify === "off") continue; + if (state.notify === "session" && sessionId !== state.sessionId) continue; + if (category === "detail" && state.notify !== "all") continue; + // The active session reads as a plain conversation β€” no session id + // prefix. Other sessions (notify=all) get a small label to distinguish. + const body = sessionId === state.sessionId + ? text + : "<b>" + shortId(sessionId) + "</b>\n" + text; + this.push(chatId, body, { replyTo: state.lastUserMessageId }); + } + } + + /** Coalescing push sender: one in-flight send per chat, pending text merged. */ + push(chatId, text, opts = {}) { + const existing = this.pendingSends.get(chatId); + if (existing !== undefined) { + this.pendingSends.set(chatId, existing + "\n\n" + text); + return; + } + this.pendingSends.set(chatId, text); + this.pendingOpts.set(chatId, opts); + void this.#drain(chatId); + } + + notifyAll(text) { + for (const chatId of this.chats.keys()) this.push(chatId, text); + } +} diff --git a/packages/telegram-remote/lib/stream.js b/packages/telegram-remote/lib/stream.js new file mode 100644 index 0000000..19362be --- /dev/null +++ b/packages/telegram-remote/lib/stream.js @@ -0,0 +1,883 @@ +/** + * Live streaming engine: subscribes to the harness mux event stream (the same + * stream the web UI uses) and renders agent work into Telegram as ONE + * live-edited message per reply, in a clean structured layout: + * + * πŸ”΅ THINKING β€” live reasoning preview (beginning + end), quote-boxed + * & italic so it can never be confused with the answer + * 🟒 TOOLS β€” every tool call with live status (β‹― running / βœ… done / ⚠️ error) + * 🟣 REPLY β€” live token stream once text starts + * ⏳ n s β€” elapsed timer footer + * + * Every header is a colored bullet + bold caps; thinking content sits in + * a <blockquote> card (accent-colored bar) with italic text. + * + * Final edit keeps the same structure but renders the full formatted markdown + * (code, bold, …), with long replies split into follow-up messages. + * + * Also handles interactive frames: agent questions (ask_user_question) and + * tool-approval requests, forwarded to Telegram with buttons. + */ + +import { esc } from "./bot.js"; +import { callApi, contentText, hasText, markdownToHtml, sleep, truncate } from "./util.js"; + +// Colored bullet + bold caps: every section gets its own accent color. +// (Telegram bots cannot set text colors, so the colored emoji bullets and +// the blockquote accent bar are the strongest color levers available.) +const THINKING_HEADER = "πŸ”΅ <b>THINKING</b>"; +const TOOLS_HEADER = "🟒 <b>TOOLS</b>"; +const REPLY_HEADER = "🟣 <b>REPLY</b>"; +// Long underscore rule drawn under every section header β€” heavy +// box-drawing line with a sparkle star in the middle (user's pick: B). +const HEADER_RULE = "━━━━━━━━━━━ ✦ ━━━━━━━━━━━"; +const DIVIDER = "─────── β‹†β‹…β˜†β‹…β‹† ───────"; +const EDIT_INTERVAL_MS = 800; +const REASONING_HEAD = 350; // beginning of thinking shown in the bubble +const REASONING_TAIL = 350; // end of thinking shown in the bubble +const TEXT_PREVIEW = 2800; +const TOOLS_MAX = 6; +const EDIT_MAX = 3800; // Telegram edit limit is 4096; stay under for HTML overhead +const CHUNK_MAX = 4000; +const DONE_STREAM_TTL_MS = 5 * 60_000; // drop finished streams after 5min +const MAX_PHOTOS_PER_STREAM = 5; // photos delivered per stream (per turn) +const MAX_URL_IMAGE_BYTES = 10 * 1024 * 1024; // Telegram sendPhoto cap (10 MB) +const URL_FETCH_TIMEOUT_MS = 30_000; + +/** Drop long-finished streams so the global map stays bounded. */ +function pruneStreams(streams) { + const now = Date.now(); + for (const [chatId, stream] of streams) { + if (stream.done && now - (stream.finishedAt ?? now) > DONE_STREAM_TTL_MS) { + streams.delete(chatId); + } + } +} + +/** + * Start the live mux subscription loop, or refresh the shared engine's live + * handler references on reload. Idempotent: repeated calls never create a + * second subscription (the engine is shared via globalThis). + * @param {object} state - PluginState (ctx, bot, log). + * @returns {boolean} true when the subscription loop is running (or already + * running from an earlier apply); false when apiProxy or apiProxy.events.mux + * is not yet available. + */ +export function setupStreaming(state) { + const api = state.ctx.get("apiProxy"); + if (!api?.events?.mux) { + state.log("streaming unavailable: no apiProxy.events.mux"); + return false; + } + // Engine lives in globalThis so HMR reloads REPLACE the handler reference + // while keeping ONE mux subscription. (The old per-apply guard froze the + // first-loaded code in memory β€” later fixes never reached the running bot.) + const engine = (globalThis.__dshTgStreamEngine ??= { + streams: new Map(), + started: false, + abort: null, + scheduleEdit: null, + handleFrame: null, + }); + const streams = engine.streams; + + // One shared edit timer for all chats: chunks arriving within the same + // interval coalesce into a single Telegram edit instead of one timer each. + let editTimer = null; + const scheduleEdit = (chatId) => { + const stream = streams.get(chatId); + if (!stream || stream.msgId == null || stream.done || stream.editing) return; + stream.editing = true; + if (editTimer == null) { + editTimer = setTimeout(() => { + editTimer = null; + for (const current of streams.values()) { + if (!current.editing) continue; + current.editing = false; + if (current.msgId == null || current.done) continue; + current.lastEdit = Date.now(); + state.bot.editMessageText(current.chatId, current.msgId, buildPreview(current)).catch(() => {}); + } + }, EDIT_INTERVAL_MS); + } + }; + + // Publish the LIVE handler: every reload replaces these with the newest + // code, and the single subscription loop below dispatches through them. + engine.scheduleEdit = scheduleEdit; + engine.handleFrame = (frame) => handleFrame(state, streams, scheduleEdit, frame); + + // Self-healing subscription: if the mux stream ever closes (even cleanly + // or with an error), reconnect after 5s instead of dying silently β€” + // a dead stream used to mean the bot stopped delivering replies forever. + const connect = (abort) => { + const run = async () => { + while (!abort.signal.aborted) { + const subscribedAt = Date.now(); + try { + const frames = api.events.mux({ rpcId: "tg-mux-" + Math.random().toString(36).slice(2, 10), payload: {} }, abort.signal); + state.log("mux connected"); + for await (const frame of frames) { + // Replay guard: after a reconnect the stream may re-deliver old + // events; only frames newer than the subscription (15s grace) + // are treated as live. + const ft = frame?.payload?.event?.time ?? 0; + if (ft > 0 && ft < subscribedAt - 15000) continue; + try { + engine.handleFrame?.(frame); + } catch (error) { + state.log("mux frame handler failed: " + (error.stack ?? error.message)); + } + } + state.log("mux stream closed β€” reconnecting in 5s"); + } catch (error) { + if (abort.signal.aborted) return; + state.log("mux stream ended: " + (error.message ?? error) + " β€” reconnecting in 5s"); + } + await sleep(5000); + } + }; + return run(); + }; + + if (!engine.started || !engine.loopAlive) { + // Restart a dead loop (or replace a half-dead one) with a fresh + // controller; each loop captures its own abort signal so superseded + // loops exit cleanly and never double-subscribe. + engine.abort?.abort(); + const ctrl = new AbortController(); + const token = {}; + engine.abort = ctrl; + engine.loopToken = token; + engine.started = true; + engine.loopAlive = true; + engine.loopPromise = connect(ctrl).catch(() => {}).finally(() => { + if (engine.loopToken === token) { + engine.loopAlive = false; + engine.loopToken = null; + engine.loopPromise = null; + } + }); + } + + return true; +} + +function handleFrame(state, streams, scheduleEdit, frame) { + const payload = frame?.payload; + if (!payload || typeof payload !== "object") return; + switch (payload.type) { + case "session/event": + handleSessionEvent(state, streams, scheduleEdit, payload); + break; + case "question/requested": + handleQuestionRequested(state, payload, frame.rpcId); + break; + case "approval/requested": + handleApprovalRequested(state, payload, frame.rpcId); + break; + default: + break; + } +} + +function interestedChats(state, sessionId) { + const chats = []; + for (const [chatId, chat] of state.chats) { + if (chat.notify === "off") continue; + if (chat.notify === "session" && sessionId !== chat.sessionId) continue; + chats.push({ chatId, chat, active: sessionId === chat.sessionId }); + } + return chats; +} + +function handleSessionEvent(state, streams, scheduleEdit, frame) { + const sessionId = frame.sessionId; + const event = frame.event; + if (!sessionId || !event) return; + const chats = interestedChats(state, sessionId); + if (chats.length === 0) return; + + switch (event.type) { + case "turn/start": { + for (const { chatId, chat, active } of chats) { + if (!active) continue; + // Reuse a live stream from a previous module instance (HMR reload + // mid-turn) instead of posting a duplicate "Thinking" message. The + // posting flag guards against concurrent mux subscribers racing while + // the first send is still in flight (msgId not yet assigned). + const existing = streams.get(chatId); + if (existing && !existing.done && existing.sessionId === sessionId) { + existing.startedAt = Date.now(); + existing.editing = false; + if (existing.msgId != null) scheduleEdit(chatId); + continue; + } + if (existing && !existing.done && existing.posting) continue; + const stream = { + chatId, + sessionId, + msgId: null, + posting: false, + text: "", + reasoning: "", + tools: new Map(), + sentImages: new Set(), + startedAt: Date.now(), + lastEdit: 0, + editing: false, + done: false, + }; + pruneStreams(streams); + streams.set(chatId, stream); + stream.posting = true; + state.log("streaming turn β†’ chat " + chatId + " session " + sessionId.slice(0, 12)); + state.bot.send(chatId, THINKING_HEADER + "\n" + HEADER_RULE + "\n<i>⏳ 0s</i>", { replyToMessageId: chat.lastUserMessageId }) + .then((ids) => { + stream.posting = false; + const current = streams.get(chatId); + if (current === stream && current.msgId == null) current.msgId = ids[0]; + state.log("thinking stub posted: chat " + chatId + " msg " + ids[0]); + }) + .catch((e2) => { stream.posting = false; state.log("stub send failed: " + (e2?.message ?? e2)); }); + } + break; + } + case "assistant/chunk": { + const chunk = event.data?.chunk; + if (!chunk) break; + for (const { chatId, active } of chats) { + if (!active) continue; + const stream = streams.get(chatId); + if (!stream || stream.done) continue; + applyChunk(stream, chunk); + scheduleEdit(chatId); + } + break; + } + case "reasoning-chunks": { + // Batched reasoning deltas from the mux stream (the high-frequency + // carrier for thinking text). texts[] are individual delta strings. + const texts = event.data?.texts; + if (!Array.isArray(texts) || texts.length === 0) break; + const joined = texts.join(""); + if (!joined) break; + for (const { chatId, active } of chats) { + if (!active) continue; + const stream = streams.get(chatId); + if (!stream || stream.done) continue; + stream.reasoning += joined; + if (stream.reasoning.length > 20000) stream.reasoning = stream.reasoning.slice(-20000); + scheduleEdit(chatId); + } + break; + } + case "assistant/message": { + // Only real text blocks count as the reply. Tool-call-only messages + // (the model asking to use tools) are already shown live in the Tools + // section β€” finalizing on them would print raw JSON as the reply. + const blocks = event.data?.message?.content; + const imageRefs = collectImageRefs(blocks); + if (!hasText(blocks)) { + // Image-only message (no text bubble): still deliver the photos so + // the turn's images are never silently dropped. + if (imageRefs.length > 0) { + for (const { chatId, active } of chats) { + if (!active) continue; + sendImageRefs(state, chatId, streams.get(chatId) ?? null, sessionId, imageRefs, "πŸ–Ό"); + } + } + break; + } + const content = contentText(blocks, { textOnly: true }); + if (!content) break; + // Backup capture: the final message also carries full reasoning blocks, + // so the thinking snippet survives even if delta events were missed. + const reasoningText = (Array.isArray(blocks) ? blocks : []) + .filter((b) => b?.type === "reasoning") + .map((b) => (typeof b.text === "string" ? b.text : "")) + .join("\n") + .trim(); + for (const { chatId, active } of chats) { + if (!active) continue; + const stream = streams.get(chatId); + state.log("assistant/message β†’ chat " + chatId + " stream=" + (stream ? "yes" : "no")); + if (stream) { + if (reasoningText && reasoningText.length > stream.reasoning.length) stream.reasoning = reasoningText; + stream.done = true; + stream.finishedAt = Date.now(); + finalizeReply(state, stream, content); + } else { + state.bot.send(chatId, REPLY_HEADER + "\n" + markdownToHtml(truncate(content, 3600)), { replyToMessageId: state.chatState(chatId).lastUserMessageId }) + .catch((e) => state.log("fallback send failed: " + (e?.message ?? e))); + } + } + // Photos after the text is finalized: refs already sent via tool/result + // are skipped by the per-stream dedupe. + if (imageRefs.length > 0) { + for (const { chatId, active } of chats) { + if (!active) continue; + const stream = streams.get(chatId); + if (stream) sendImageRefs(state, chatId, stream, sessionId, imageRefs); + } + } + break; + } + case "tool/call": { + const callId = event.data?.callId; + const name = event.data?.name ?? "?"; + for (const { chatId, active } of chats) { + if (!active) continue; + const stream = streams.get(chatId); + if (stream && !stream.done) { + stream.tools.set(callId, { name, status: "running" }); + scheduleEdit(chatId); + } + } + break; + } + case "tool/result": { + const content = event.data?.message?.content; + const imageRefs = collectImageRefs(content); + const imageUrls = imageUrlsInContent(content); + const hasImages = imageRefs.length > 0 || imageUrls.length > 0; + const callId = event.data?.message?.source?.callId; + if (callId) { + const isError = !!event.data?.message?.isError; + for (const { chatId, active } of chats) { + if (!active) continue; + const stream = streams.get(chatId); + if (!stream || stream.done) continue; + const tool = stream.tools.get(callId); + if (tool) { + tool.status = isError ? "error" : "done"; + scheduleEdit(chatId); + } + } + } + // Deliver images as soon as the tool result arrives; refs and URLs are + // deduped per stream (a URL echoed in the final text is NOT re-sent β€” + // assistant/message only collects refs). + if (hasImages) { + for (const { chatId, active } of chats) { + if (!active) continue; + const stream = streams.get(chatId); + if (!stream) continue; + sendImageRefs(state, chatId, stream, sessionId, imageRefs); + sendImageUrls(state, chatId, stream, imageUrls); + } + } + break; + } + case "turn/end": { + const reason = event.data?.reason; + if (!reason) break; + for (const { chatId, active } of chats) { + if (!active) continue; + const stream = streams.get(chatId); + if (!stream) continue; + if (reason.kind === "interrupted") { + if (!stream.done) { + stream.done = true; + state.bot.editMessageText(chatId, stream.msgId, "⏹ <b>Stopped</b>").catch(() => {}); + } + } else if (reason.kind !== "completed" && !stream.done) { + stream.done = true; + const detail = reason.error?.message ? ": " + truncate(String(reason.error.message), 300) : ""; + state.bot.editMessageText(chatId, stream.msgId, "⚠️ <b>" + esc(reason.kind) + "</b>" + esc(detail)).catch(() => {}); + } + } + break; + } + case "user/message": { + const content = contentText(event.data?.content); + if (!content) break; + if (state.isRecentPrompt(sessionId, content)) break; + for (const { chatId, active } of chats) { + if (active) continue; + state.bot.send(chatId, "πŸ§‘ " + truncate(content, 600), { replyToMessageId: state.chatState(chatId).lastUserMessageId }).catch(() => {}); + } + break; + } + default: + break; + } +} + +/** Final, structured reply: edit the live message, then send overflow chunks. */ +function finalizeReply(state, stream, content) { + const elapsed = Math.max(1, Math.round((Date.now() - stream.startedAt) / 1000)); + const stats = []; + if (stream.tools.size) stats.push("πŸ”§ " + stream.tools.size + (stream.tools.size === 1 ? " tool" : " tools")); + stats.push("⏱ " + elapsed + "s"); + const footer = DIVIDER + "\n<i>" + stats.join(" Β· ") + "</i>"; + // The live bubble is the SAME message as the final one β€” so the thinking + // section stays in it, above the answer, forever (users scroll back to it). + const thinking = stream.reasoning + ? THINKING_HEADER + "\n" + HEADER_RULE + "\n" + reasoningSnippet(stream.reasoning) + "\n\n" + : ""; + const full = thinking + REPLY_HEADER + "\n" + HEADER_RULE + "\n" + markdownToHtml(content) + "\n" + footer; + + const chunks = splitByLength(full, CHUNK_MAX); + const fail = (e) => state.log("finalize send failed: " + (e?.message ?? e)); + let head = chunks[0]; + const tail = chunks.slice(1); + if (stream.msgId != null) { + // The live bubble is edited in place. If the head exceeds the edit + // limit, cut it at a line boundary and send the remainder separately β€” + // content is NEVER dropped (the old code silently lost the tail). + if (head.length > EDIT_MAX) { + let cut = head.lastIndexOf("\n", EDIT_MAX - 60); + if (cut < EDIT_MAX * 0.6) cut = EDIT_MAX - 60; + tail.unshift(head.slice(cut)); + head = head.slice(0, cut) + "\n…"; + } + state.bot.editMessageText(stream.chatId, stream.msgId, head).catch(fail); + } else { + state.bot.send(stream.chatId, head, { replyToMessageId: state.chatState(stream.chatId).lastUserMessageId }).catch(fail); + } + for (const chunk of tail) { + state.bot.send(stream.chatId, chunk).catch(fail); + } +} + +function splitByLength(text, max) { + if (text.length <= max) return [text]; + const chunks = []; + let rest = text; + while (rest.length > max) { + let cut = rest.lastIndexOf("\n", max); + if (cut <= 0) cut = max; + chunks.push(rest.slice(0, cut)); + rest = rest.slice(cut); + } + if (rest.length > 0) chunks.push(rest); + return chunks; +} + +/* ── image delivery ── */ + +/** + * Collect every image reference inside a content array, descending into + * nested tool-result content the way the harness live attachment route does. + * @param {unknown} content - an event content array (or nested tool-result content). + * @returns {Array<object>} unique ImageAttachmentRef values, first-seen order. + */ +function collectImageRefs(content) { + const refs = new Map(); + if (!Array.isArray(content)) return []; + const pending = [...content]; + while (pending.length > 0) { + const value = pending.pop(); + if (typeof value !== "object" || value === null || Array.isArray(value)) continue; + if (value.type === "image" && typeof value.attachment === "object" && value.attachment !== null) { + const ref = value.attachment; + if (typeof ref.attachmentId === "string") refs.set(ref.attachmentId, ref); + } + if (Array.isArray(value.content)) { + for (const item of value.content) pending.push(item); + } + } + return [...refs.values()]; +} + +// Matches http(s) URLs whose text contains .png/.jpg/.jpeg/.webp/.gif +// (case-insensitive), including extension-in-query URLs such as +// /view?filename=abc.png&type=output; the trailing run also absorbs any +// query/params after the extension. +const IMAGE_URL_RE = /https?:\/\/[^\s"'<>`)]+\.(?:png|jpe?g|webp|gif)[^\s"'<>`)]*/gi; + +/** + * Collect every http(s) image URL inside a content array, from text blocks, + * descending into nested tool-result content. Returns unique URLs. + * @param {unknown} content - an event content array. + * @returns {string[]} unique absolute image URLs, first-seen order. + */ +function imageUrlsInContent(content) { + const urls = new Set(); + if (!Array.isArray(content)) return []; + const pending = [...content]; + while (pending.length > 0) { + const value = pending.pop(); + if (typeof value !== "object" || value === null || Array.isArray(value)) continue; + if (value.type === "text" && typeof value.text === "string") { + for (const match of value.text.matchAll(IMAGE_URL_RE)) { + urls.add(String(match[0]).replace(/[.,;]+$/, "")); + } + } + if (Array.isArray(value.content)) { + for (const item of value.content) pending.push(item); + } + } + return [...urls]; +} + +/** + * Send every collected image ref to one chat for the current stream, deduped. + * Keys are reserved synchronously BEFORE the async send so concurrent events + * can never double-deliver; a failed send is logged and skipped. + * @param {object} state - PluginState (ctx, bot, log). + * @param {number|string} chatId - Telegram chat id. + * @param {object|null} stream - the per-chat stream, or null for no dedupe. + * @param {string} sessionId - session owning the refs (RPC fallback reads). + * @param {Array<object>} refs - ImageAttachmentRef list. + * @param {string} [caption] - optional photo caption. + */ +function sendImageRefs(state, chatId, stream, sessionId, refs, caption) { + if (!Array.isArray(refs)) return; + for (const ref of refs) { + if (!ref || typeof ref !== "object" || typeof ref.attachmentId !== "string") continue; + if (stream) { + if (!stream.sentImages) stream.sentImages = new Set(); + if (stream.sentImages.has(ref.attachmentId)) continue; + if (stream.sentImages.size >= MAX_PHOTOS_PER_STREAM) { + state.log("photo limit reached: chat " + chatId + " (max " + MAX_PHOTOS_PER_STREAM + ")"); + continue; + } + stream.sentImages.add(ref.attachmentId); + } + void deliverRef(state, chatId, sessionId, ref, caption) + .catch((e) => state.log("photo send failed: " + (e?.message ?? e))); + } +} + +/** + * Send every collected image URL to one chat for the current stream, deduped. + * @param {object} state - PluginState. + * @param {number|string} chatId - Telegram chat id. + * @param {object|null} stream - the per-chat stream, or null for no dedupe. + * @param {string[]} urls - unique image URLs. + */ +function sendImageUrls(state, chatId, stream, urls) { + if (!Array.isArray(urls)) return; + for (const url of urls) { + if (typeof url !== "string" || !url) continue; + if (stream) { + if (!stream.sentImages) stream.sentImages = new Set(); + if (stream.sentImages.has(url)) continue; + if (stream.sentImages.size >= MAX_PHOTOS_PER_STREAM) { + state.log("photo limit reached: chat " + chatId + " (max " + MAX_PHOTOS_PER_STREAM + ")"); + continue; + } + stream.sentImages.add(url); + } + void deliverUrl(state, chatId, url) + .catch((e) => state.log("photo url send failed: " + (e?.message ?? e))); + } +} + +/** Read one attachment ref and deliver it as a Telegram photo. */ +async function deliverRef(state, chatId, sessionId, ref, caption) { + if (ref.bytes > 50_000_000) { + state.log("skipped oversized photo (" + ref.bytes + " bytes): " + ref.attachmentId); + return; + } + let data; + const attachments = state.ctx.get("attachments"); + if (attachments) { + const stored = await attachments.readImage(ref); + data = stored.data; + } else { + // RPC fallback: the same route the web client uses to resolve an image. + const value = await callApi(state.ctx, "sessions", "attachment", { + sessionId, + attachmentId: ref.attachmentId, + }); + data = Buffer.from(String(value?.data ?? ""), "base64"); + } + if (data.length === 0) throw new Error("empty image data for " + ref.attachmentId); + await state.bot.sendPhoto(chatId, Buffer.from(data), { + filename: ref.name || extensionForMediaType(ref.mediaType), + caption, + }); +} + +/** Download one image URL and deliver it as a Telegram photo. */ +async function deliverUrl(state, chatId, url) { + const buffer = await fetchImageBytes(state, url); + if (!buffer) return; + await state.bot.sendPhoto(chatId, buffer, { filename: filenameFromUrl(url) }); +} + +/** + * Download one image URL into a Buffer with content-type and size guards. + * Never throws: every failure is logged and returns null. + * @param {object} state - PluginState (for logging). + * @param {string} url - absolute http(s) image URL. + * @returns {Promise<Buffer|null>} the bytes, or null when not an image, too + * large (over 10 MB), or the fetch failed. + */ +async function fetchImageBytes(state, url) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), URL_FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { signal: ctrl.signal }); + if (!res.ok) { + state.log("photo url fetch failed: HTTP " + res.status + " " + url); + return null; + } + const ctype = String(res.headers.get("content-type") ?? "").toLowerCase(); + if (!ctype.startsWith("image/")) { + state.log("photo url not an image (" + ctype + "): " + url); + return null; + } + const declared = Number(res.headers.get("content-length") ?? "0"); + if (declared > MAX_URL_IMAGE_BYTES) { + state.log("photo url too large (" + declared + " bytes): " + url); + return null; + } + if (!res.body) { + const buffer = Buffer.from(await res.arrayBuffer()); + if (buffer.length > MAX_URL_IMAGE_BYTES) { + state.log("photo url too large (" + buffer.length + " bytes): " + url); + return null; + } + return buffer; + } + const reader = res.body.getReader(); + const chunks = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_URL_IMAGE_BYTES) { + await reader.cancel(); + state.log("photo url too large (" + total + " bytes): " + url); + return null; + } + chunks.push(value); + } + return Buffer.concat(chunks); + } catch (error) { + state.log("photo url fetch failed: " + (error?.message ?? error) + " " + url); + return null; + } finally { + clearTimeout(timer); + } +} + +/** Map an image media type to a Telegram filename. */ +function extensionForMediaType(mediaType) { + if (mediaType === "image/jpeg") return "image.jpg"; + if (mediaType === "image/webp") return "image.webp"; + if (mediaType === "image/gif") return "image.gif"; + return "image.png"; +} + +/** Derive a display filename from an image URL. */ +function filenameFromUrl(url) { + try { + const parsed = new URL(url); + const base = parsed.pathname.split("/").pop(); + if (base && /\.(png|jpe?g|webp|gif)$/i.test(base)) return base; + const qname = parsed.searchParams.get("filename"); + if (qname && /\.(png|jpe?g|webp|gif)$/i.test(qname)) return qname; + } catch {} + return "image.png"; +} + +function applyChunk(stream, chunk) { + const ctype = chunk.type; + if (ctype === "reasoning-delta") { + if (typeof chunk.text === "string") stream.reasoning += chunk.text; + } else if (ctype === "text-delta") { + if (typeof chunk.text === "string") stream.text += chunk.text; + } else if (ctype === "block-end") { + const block = chunk.block; + if (!block) return; + if (block.type === "text" && typeof block.text === "string") { + stream.text = block.text; + } else if (block.type === "reasoning" && typeof block.text === "string") { + stream.reasoning = block.text; + } + } else if (ctype === "delta" && chunk.block) { + // Older harness shape: delta with an inline block + const delta = typeof chunk.block.text === "string" ? chunk.block.text : ""; + if (!delta) return; + if (chunk.block.type === "reasoning") stream.reasoning += delta; + else if (chunk.block.type === "text") stream.text += delta; + } + if (stream.text.length > 20000) stream.text = stream.text.slice(-20000); + if (stream.reasoning.length > 20000) stream.reasoning = stream.reasoning.slice(-20000); +} + +/** + * Compact head+tail preview of long thinking text β€” the beginning and the + * end of the model's reasoning, with an ellipsis in between. Code-point safe + * (emoji/surrogates never split) and HTML-escaped. + */ +function reasoningSnippet(raw) { + const text = String(raw ?? "").trim(); + if (!text) return ""; + const chars = Array.from(text); + // Quote-boxed + italic: the thinking renders as a distinct indented card + // with a colored left border β€” impossible to confuse with the answer. + const body = (chars.length <= REASONING_HEAD + REASONING_TAIL) + ? "<i>" + esc(text) + "</i>" + : "<i>" + esc(chars.slice(0, REASONING_HEAD).join("")) + "</i>\n<i>… thinking continues …</i>\n<i>" + esc(chars.slice(-REASONING_TAIL).join("")) + "</i>"; + return "<blockquote>" + body + "</blockquote>"; +} + +/** Render the live message with clean, clearly separated sections. */ +function buildPreview(stream) { + const elapsed = Math.max(0, Math.round((Date.now() - stream.startedAt) / 1000)); + const sections = []; + + if (stream.reasoning && !stream.done) { + sections.push(THINKING_HEADER + "\n" + HEADER_RULE + "\n" + reasoningSnippet(stream.reasoning)); + } + + if (stream.tools.size > 0 && !stream.done) { + const lines = []; + let idx = 0; + for (const tool of stream.tools.values()) { + if (idx >= TOOLS_MAX) { + lines.push("+ " + (stream.tools.size - idx) + " more"); + break; + } + idx += 1; + const icon = tool.status === "done" ? "βœ…" : tool.status === "error" ? "⚠️" : "β‹―"; + lines.push(icon + " <code>" + esc(tool.name) + "</code>"); + } + sections.push(TOOLS_HEADER + "\n" + HEADER_RULE + "\n" + lines.join(" ")); + } + + if (stream.text) { + sections.push(REPLY_HEADER + "\n" + HEADER_RULE + "\n" + truncate(stream.text.trim(), TEXT_PREVIEW)); + } else if (!stream.reasoning && stream.tools.size === 0) { + sections.push(THINKING_HEADER + "\n" + HEADER_RULE); + } + + // Blank line between every section keeps Thinking / Tools / Reply apart. + sections.push(DIVIDER + "\n<i>⏳ " + elapsed + "s</i>"); + return sections.join("\n\n"); +} + +/* ── interactive: questions & approvals ── */ + +const QUESTION_CB = "qa:"; +const APPROVE_CB = "ap:y:"; +const REJECT_CB = "ap:n:"; + +function handleQuestionRequested(state, payload, rpcId) { + const sessionId = payload.sessionId; + const questions = payload.questions ?? []; + if (!questions.length) return; + const chats = interestedChats(state, sessionId); + if (!chats.length) return; + for (const { chatId, active } of chats) { + if (!active) continue; + const q = questions[0]; + const rows = (q.options ?? []).map((opt, idx) => [{ + text: truncate(opt.label, 40), + callback_data: QUESTION_CB + rpcId + ":" + idx, + }]); + const keyboard = rows.length ? { inline_keyboard: rows } : undefined; + let text = "❓ <b>" + esc(q.question) + "</b>"; + if (q.header) text = "❓ <b>" + esc(q.header) + "</b>\n" + esc(q.question); + if (q.detail) text += "\n<i>" + esc(truncate(q.detail, 300)) + "</i>"; + if (q.multiSelect) text += "\n<i>(multi-select: tap each, then send your final answer as a plain message)</i>"; + else text += "\n<i>Tap an option, or reply with your own answer as a plain message.</i>"; + const chat = state.chatState(chatId); + chat.pendingQuestion = { rpcId, sessionId, questionId: q.id, options: q.options ?? [], multiSelect: !!q.multiSelect, at: Date.now() }; + state.saveState(); + state.bot.send(chatId, text, { replyMarkup: keyboard }).catch(() => {}); + } +} + +function handleApprovalRequested(state, payload, rpcId) { + const sessionId = payload.sessionId; + // The harness identifies this approval by ITS OWN approvalId (separate from + // the envelope rpcId) β€” answering with the rpcId was silently rejected. + const approvalId = payload.approvalId; + const chats = interestedChats(state, sessionId); + if (!chats.length) return; + const text = "πŸ›‘οΈ <b>Permission needed</b>\n" + + "The AI wants to use <code>" + esc(payload.toolName ?? "a tool") + "</code>" + + (payload.reason ? "\n<i>" + esc(truncate(payload.reason, 300)) + "</i>" : ""); + const keyboard = { + inline_keyboard: [ + [ + { text: "βœ… Allow once", callback_data: APPROVE_CB + rpcId }, + { text: "❌ Reject", callback_data: REJECT_CB + rpcId }, + ], + ], + }; + for (const { chatId, active } of chats) { + if (!active) continue; + const chat = state.chatState(chatId); + chat.pendingApproval = { sessionId, rpcId, approvalId, at: Date.now() }; + state.saveState(); + state.bot.send(chatId, text, { replyMarkup: keyboard }).catch(() => {}); + } +} + +export function handleInteractiveCallback(state, chatId, data) { + if (data.startsWith(QUESTION_CB)) { + const rest = data.slice(QUESTION_CB.length); + const sep = rest.lastIndexOf(":"); + if (sep < 0) return false; + const rpcId = rest.slice(0, sep); + const idx = Number(rest.slice(sep + 1)); + const chat = state.chatState(chatId); + const pending = chat.pendingQuestion; + if (!pending || pending.rpcId !== rpcId || !pending.options[idx]) return true; + const label = pending.options[idx].label; + const payload = { + sessionId: pending.sessionId, + answer: { answers: [{ id: pending.questionId, selected: [label] }] }, + }; + void answerQuestion(state, rpcId, payload); + chat.pendingQuestion = undefined; + state.saveState(); + return true; + } + if (data.startsWith(APPROVE_CB) || data.startsWith(REJECT_CB)) { + const approve = data.startsWith(APPROVE_CB); + const rpcId = data.slice(approve ? APPROVE_CB.length : REJECT_CB.length); + const api = state.ctx.get("apiProxy"); + if (!api?.respond) return true; + const chat = state.chatState(chatId); + const pending = chat.pendingApproval; + // Exactly the wire shape the harness validates: + // { type: "client-response", rpcId, result: { ok: true, value: {...} } } + const value = { + sessionId: pending?.sessionId ?? "", + approvalId: pending?.approvalId ?? rpcId, + outcome: approve ? "allowed-once" : "rejected", + }; + if (pending) { + chat.pendingApproval = undefined; + state.saveState(); + } + api.respond({ type: "client-response", rpcId, result: { ok: true, value } }) + .then((receipt) => { + state.log("approval respond accepted=" + (receipt ? receipt.accepted : "?") + " " + JSON.stringify(receipt)); + const note = approve + ? "βœ… <b>Allowed</b> β€” the AI can continue." + : "❌ <b>Rejected</b> β€” the AI will skip that action."; + state.bot.send(chatId, note).catch(() => {}); + }) + .catch((error) => { + state.log("approval respond failed: " + error.message); + state.bot.send(chatId, "⚠️ Could not send that permission. Try again, or allow it in the web GUI.").catch(() => {}); + }); + return approve ? "Allowed" : "Rejected"; + } + return false; +} + +async function answerQuestion(state, rpcId, payload) { + const api = state.ctx.get("apiProxy"); + if (!api?.respond) { + state.log("question respond unavailable"); + return; + } + try { + const receipt = await api.respond({ type: "client-response", rpcId, result: { ok: true, value: payload } }); + state.log("question respond accepted=" + (receipt ? receipt.accepted : "?") + " " + JSON.stringify(receipt)); + } catch (error) { + state.log("question respond failed: " + error.message); + } +} diff --git a/packages/telegram-remote/lib/ui.js b/packages/telegram-remote/lib/ui.js new file mode 100644 index 0000000..14d6b69 --- /dev/null +++ b/packages/telegram-remote/lib/ui.js @@ -0,0 +1,57 @@ +/** + * Tiny UI kit: consistent, pretty Telegram message composition. + * All helpers return HTML-safe strings (callers escape user data). + */ + +/** Soft horizontal divider. */ +export const DIVIDER = "─────── β‹†β‹…β˜†β‹…β‹† ───────"; +/** Slim divider for tight layouts. */ +export const SLIM = "────────────"; + +/** Section heading with a leading emoji. */ +export function heading(emoji, title) { + return "<b>" + emoji + " " + title + "</b>"; +} + +/** Hero header for welcome/start pages. */ +export function hero(title, subtitle) { + return "✨ <b>" + title + "</b>" + (subtitle ? "\n<i>" + subtitle + "</i>" : ""); +} + +/** One feature/fact row: emoji + label + detail. */ +export function row(emoji, label, detail) { + return emoji + " <b>" + label + "</b>" + (detail ? " β€” " + detail : ""); +} + +/** Bullet line with an optional muted hint. */ +export function bullet(text, hint) { + return "β€’ " + text + (hint ? " <i>(" + hint + ")</i>" : ""); +} + +/** Command line: /cmd + short hint, monospace. */ +export function command(cmd, hint) { + return "<code>" + cmd + "</code>" + (hint ? " β€” " + hint : ""); +} + +/** Muted small print. */ +export function small(text) { + return "<i>" + text + "</i>"; +} + +/** Status chip: colored dot + word. */ +export function chip(dot, word) { + return "<b>" + dot + "</b> " + word; +} + +/** Numbered list item with title + meta line. */ +export function numbered(index, title, meta) { + return (index + 1) + ". <b>" + title + "</b>" + (meta ? "\n <i>" + meta + "</i>" : ""); +} + +/** Keyboard: rows of buttons. */ +export function inlineKeyboard(rows) { + return { inline_keyboard: rows.map((rowButtons) => rowButtons.map((b) => ({ + text: b.text, + callback_data: b.data ?? b.text, + }))) }; +} diff --git a/packages/telegram-remote/lib/util.js b/packages/telegram-remote/lib/util.js new file mode 100644 index 0000000..bf323dc --- /dev/null +++ b/packages/telegram-remote/lib/util.js @@ -0,0 +1,202 @@ +/** + * Shared helpers for dsh-telegram-remote. + */ + +import { join } from "node:path"; + +export function dshHome() { + return process.env.DSH_HOME || join(process.env.USERPROFILE || process.env.HOME || ".", ".dsh"); +} + +export function nowIso() { + return new Date().toISOString(); +} + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function truncate(text, max) { + const str = String(text ?? ""); + if (str.length <= max) return str; + return str.slice(0, max) + "\n…[truncated]"; +} + +export function pretty(value, max = 3000) { + try { + return truncate(JSON.stringify(value, null, 2), max); + } catch { + return truncate(String(value), max); + } +} + +export function fmtAge(ts) { + if (typeof ts !== "number") return "?"; + const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000)); + if (sec < 60) return sec + "s ago"; + if (sec < 3600) return Math.floor(sec / 60) + "m ago"; + if (sec < 86400) return Math.floor(sec / 3600) + "h ago"; + return Math.floor(sec / 86400) + "d ago"; +} + +export function shortId(sessionId) { + const id = String(sessionId ?? ""); + return id.length > 12 ? id.slice(0, 8) + "…" + id.slice(-6) : id; +} + +const HTML_CACHE_MAX = 64; +const htmlCache = new Map(); + +/** Structured markdown β†’ Telegram-HTML renderer (memoized). */ +export function markdownToHtml(md) { + const text = String(md ?? ""); + const cached = htmlCache.get(text); + if (cached !== undefined) return cached; + const escape = (s) => String(s).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); + const inline = (s) => s + .replace(/`([^`]+)`/g, "<code>$1</code>") + .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>") + .replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, "$1<i>$2</i>") + .replace(/__([^_]+)__/g, "<u>$1</u>") + .replace(/~~([^~]+)~~/g, "<s>$1</s>") + .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2">$1</a>'); + const lines = String(text).split("\n"); + const out = []; + let inFence = false; + let fenceLang = ""; + const fence = []; + const flushFence = () => { + if (!inFence) return; + inFence = false; + const body = fence.join("\n"); + out.push((fenceLang ? "<i>" + escape(fenceLang) + "</i>\n" : "") + "<pre>" + escape(body) + "</pre>"); + fence.length = 0; + fenceLang = ""; + }; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const fenceMatch = line.match(/^\s*```(.*)$/); + if (fenceMatch) { + if (!inFence) { + inFence = true; + fenceLang = fenceMatch[1].trim(); + } else { + flushFence(); + } + continue; + } + if (inFence) { + fence.push(line); + continue; + } + const trimmed = line.trim(); + if (!trimmed) { + out.push(""); + continue; + } + const h = trimmed.match(/^(#{1,6})\s+(.+)$/); + if (h) { + const prefix = h[1].length <= 2 ? "πŸ“Œ " : h[1].length === 3 ? "β–Έ " : "β€’ "; + out.push("<b>" + prefix + inline(escape(h[2])) + "</b>"); + continue; + } + if (/^>\s?/.test(trimmed)) { + out.push("<i>" + inline(escape(trimmed.replace(/^>\s?/, ""))) + "</i>"); + continue; + } + if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { + out.push("──────────────"); + continue; + } + const ul = trimmed.match(/^([-*+])\s+(.+)$/); + if (ul) { + out.push("β€’ " + inline(escape(ul[2]))); + continue; + } + const ol = trimmed.match(/^(\d+)[.)]\s+(.+)$/); + if (ol) { + out.push("<b>" + ol[1] + ".</b> " + inline(escape(ol[2]))); + continue; + } + if (trimmed.includes("-") && /^\|?[\s:-]+\|?\s*$/.test(trimmed)) { + continue; + } + out.push(inline(escape(trimmed))); + } + flushFence(); + const result = out.join("\n"); + if (htmlCache.size >= HTML_CACHE_MAX) htmlCache.delete(htmlCache.keys().next().value); + htmlCache.set(text, result); + return result; +} + +/** Extract readable text from a session content block array. */ +export function contentText(blocks, { textOnly = false } = {}) { + if (!Array.isArray(blocks)) return ""; + return blocks + .map((b) => { + if (!b || typeof b !== "object") return ""; + if (b.type === "text" && typeof b.text === "string") return b.text; + if (b.type === "reasoning") return ""; + if (b.type === "tool-call") { + if (textOnly) return ""; + return "[" + b.name + "] " + truncate(String(b.arguments ?? ""), 120); + } + if (b.type === "tool-result") return contentText(b.content, { textOnly }); + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +/** True when a content block array contains at least one real text block. */ +export function hasText(blocks) { + if (!Array.isArray(blocks)) return false; + return blocks.some((b) => b && typeof b === "object" && b.type === "text" && typeof b.text === "string" && b.text.trim().length > 0); +} + +/** Gateway invocation with RPC-envelope unwrapping. */ +export async function invoke(gateway, namespace, method, args = {}) { + const result = await gateway.invoke({ namespace, method, args }); + if (result && typeof result === "object" && "ok" in result) { + if (result.ok === true) return result.value; + const error = result.error ?? {}; + const err = new Error(error.message ?? namespace + "." + method + " failed"); + err.code = error.code; + throw err; + } + return result; +} + +/** + * Call one harness API method the way the web client does, in-process. + * Prefers the host api-proxy service (domains: sessions, subagents, host, + * goals, workspace, skills, agentPresets, settings, credentials, llm, ...), + * falling back to the typert gateway (slash namespaces such as commands/*, + * pluginInventory/*, messageFeedback/*) when the api-proxy does not mount + * that method. Returns the unwrapped business value or throws with the + * wire error code attached. + */ +export async function callApi(ctx, domain, method, args = {}) { + const apiProxy = ctx.get("apiProxy"); + if (apiProxy && typeof apiProxy?.[domain]?.[method] === "function") { + const response = await apiProxy[domain][method]({ + rpcId: "tg-" + Math.random().toString(36).slice(2, 10), + payload: args, + }); + const result = response?.result; + if (result && typeof result === "object") { + if (result.ok === true) return result.value; + if (result.ok === false) { + const error = result.error ?? {}; + const err = new Error(error.message ?? domain + "." + method + " failed"); + err.code = error.code; + throw err; + } + } + return result; + } + const gateway = ctx.get("typertGateway"); + if (gateway) return invoke(gateway, domain, method, args); + throw new Error("no api surface for " + domain + "." + method); +} diff --git a/packages/telegram-remote/lib/whisper.js b/packages/telegram-remote/lib/whisper.js new file mode 100644 index 0000000..0dcce6c --- /dev/null +++ b/packages/telegram-remote/lib/whisper.js @@ -0,0 +1,85 @@ +/** + * Zero-dependency client for the home-lab Whishper speech-to-text server + * (g-daco/Whishper). Multipart upload + job polling over global fetch/ + * FormData/Blob (Node >= 22). Server contract (verified against + * http://192.168.31.159:8082): + * POST {base}/api/transcriptions multipart fields: file, language, + * modelSize, device (cuda|cpu), sourceUrl + * GET {base}/api/transcriptions/{id} + * Job status -1 = queued/running; statuses 0/1/2 = terminal; a successful + * terminal job has a non-empty result.text. + */ + +const POLL_INTERVAL_MS = 1500; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Transcribe an audio buffer via the Whishper server. + * @param {object} opts - { baseUrl, model, device, language, timeoutMs, maxBytes } + * @param {Buffer} audioBuffer - audio bytes to transcribe + * @param {string} filename - upload filename (e.g. "voice.ogg") + * @param {string} [mimeType] - content type for the upload blob + * @returns {Promise<string>} the transcribed text + * @throws {Error} clear message on empty baseUrl, non-Buffer input, size + * overrun, upload HTTP rejection, terminal-without-text, or timeout + */ +export async function transcribeAudio({ baseUrl, model, device, language, timeoutMs, maxBytes }, audioBuffer, filename, mimeType) { + const base = String(baseUrl ?? "").replace(/\/+$/, ""); + if (!base) throw new Error("whisper: baseUrl is empty"); + if (!Buffer.isBuffer(audioBuffer)) throw new Error("whisper: audioBuffer must be a Buffer"); + const max = Number(maxBytes) || 0; + if (max > 0 && audioBuffer.length > max) { + throw new Error("whisper: audio is " + audioBuffer.length + " bytes, limit is " + max); + } + const name = String(filename ?? "audio.bin"); + const form = new FormData(); + form.append("file", new Blob([audioBuffer], mimeType ? { type: mimeType } : {}), name); + form.append("language", String(language ?? "").trim() || "auto"); + form.append("modelSize", String(model ?? "large-v2")); + form.append("device", String(device ?? "cuda")); + form.append("sourceUrl", ""); + + let res; + try { + res = await fetch(base + "/api/transcriptions", { method: "POST", body: form }); + } catch (error) { + throw new Error("whisper: upload failed: " + error.message); + } + if (!res.ok) { + const body = (await res.text()).slice(0, 300); + throw new Error("whisper: upload rejected (HTTP " + res.status + "): " + body); + } + let created; + try { + created = await res.json(); + } catch { + throw new Error("whisper: upload response was not JSON"); + } + const id = created?.id; + if (!id) throw new Error("whisper: no job id in upload response"); + + const timeout = Math.max(Number(timeoutMs) || 120000, 1000); + const deadline = Date.now() + timeout; + for (;;) { + if (Date.now() >= deadline) { + throw new Error("whisper: transcription timed out after " + timeout + "ms"); + } + await sleep(POLL_INTERVAL_MS); + let job; + try { + const pollRes = await fetch(base + "/api/transcriptions/" + id); + if (!pollRes.ok) throw new Error("HTTP " + pollRes.status); + job = await pollRes.json(); + } catch (error) { + throw new Error("whisper: poll failed: " + error.message); + } + const text = job?.result?.text; + if (typeof text === "string" && text.trim().length > 0) return text; + if (job?.status !== -1) { + throw new Error("whisper: job finished without text (status " + String(job?.status) + ")"); + } + } +}