feat(telegram-remote): ship lib/ code, add voice transcription, chat pagination, stream photos
All checks were successful
build-and-publish / build-test (push) Successful in 29s
build-and-publish / publish (push) Has been skipped

- Add lib/ (plain-ESM plugin code, no build step) so the published
  tarball actually contains the plugin; un-ignore lib/ for this package
- Sync README: voice/audio transcription via home-lab Whishper (default
  large-v2, device cuda, language auto), /chats pagination and subagent
  hiding, whisper* config keys, updated security note
This commit is contained in:
2026-08-27 22:20:53 +07:00
parent e76c91c886
commit 25d4e0e113
11 changed files with 4058 additions and 1 deletions

View File

@@ -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/

View File

@@ -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)

View File

@@ -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("<", "&lt;")
.replaceAll(">", "&gt;");
}
/** 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<number[]>} 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));
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 <chat> <new title> — e.g. /rename 1 my todo app";
const result = await callApi(r.ctx, "sessions", "rename", { sessionId, title });
return "✏️ Renamed to <b>" + esc(result.title) + "</b>";
}
export async function cmdFork(r) {
const sessionId = await r.state.resolveSessionId(r.chatId, r.args[0]);
if (!sessionId) return "/fork <chat> — 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: <code>" + newId + "</code>\nIt's now your active chat.";
}
export async function cmdSearch(r) {
const query = r.rest;
if (!query) return "/search <text> — 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) => "• <code>" + shortId(it.sessionId) + "</code> " + 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 <chat> — 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 <folder>";
const lines = ["<b>Workspaces</b> — 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 <folder> 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 <folder path> — e.g. /ws new C:\\projects\\myapp";
const result = await callApi(r.ctx, "workspace", "create", { path });
return "📁 Workspace " + (result.created ? "created" : "already existed") + ": <b>" + esc(result.workspace?.title || result.workspace?.path || path) + "</b>";
}
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 <n> <new title> — see /workspaces";
await callApi(r.ctx, "workspace", "rename", { workspaceId: ws.id, title });
return "✏️ Workspace renamed to <b>" + esc(title) + "</b>";
}
if (action === "delete" || action === "rm") {
const n = Number(rest[0]);
const ws = items[n - 1];
if (!ws) return "/ws delete <n> — see /workspaces";
await callApi(r.ctx, "workspace", "delete", { workspaceId: ws.id });
return "🗑 Workspace deleted: " + esc(ws.title || ws.path);
}
return "/ws new <path> · /ws rename <n> <title> · /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>";
}

View File

@@ -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 &lt;id&gt;</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 };

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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,
}))) };
}

View File

@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
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);
}

View File

@@ -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) + ")");
}
}
}