feat(telegram-remote): ship lib/ code, add voice transcription, chat pagination, stream photos
- 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:
303
packages/telegram-remote/lib/bot.js
Normal file
303
packages/telegram-remote/lib/bot.js
Normal 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("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
/** Plain-text value with optional HTML tagging; safe when embedded in HTML mode. */
|
||||
export function esc(value) {
|
||||
return htmlEscape(value ?? "");
|
||||
}
|
||||
|
||||
export class TelegramBot {
|
||||
token;
|
||||
log;
|
||||
offset = 0;
|
||||
stopped = false;
|
||||
loop = null;
|
||||
pollAbort = null;
|
||||
lastPollAt = 0;
|
||||
me = null;
|
||||
seen = new Set();
|
||||
|
||||
constructor(token, log = () => {}) {
|
||||
if (!token || typeof token !== "string" || !token.includes(":")) {
|
||||
throw new Error(`telegram-remote: invalid bot token (${typeof token})`);
|
||||
}
|
||||
this.token = token;
|
||||
this.log = log;
|
||||
// Called whenever the poll cursor advances, so callers can persist it.
|
||||
this.onOffset = null;
|
||||
}
|
||||
|
||||
async call(method, params = {}, { timeoutMs = 90_000, signal } = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
// Chain an external signal (e.g. the poll abort) so stop() can kill an
|
||||
// in-flight getUpdates immediately instead of waiting for its timeout.
|
||||
// The listener is removed on completion: the poll loop reuses ONE
|
||||
// controller per request, so without cleanup listeners would accumulate.
|
||||
const onAbort = () => controller.abort();
|
||||
if (signal) {
|
||||
if (signal.aborted) controller.abort();
|
||||
else signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/bot${this.token}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = (await res.text()).slice(0, 500);
|
||||
throw new Error(`telegram ${method}: HTTP ${res.status} ${text}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
const err = new Error(`telegram ${method}: ${data.description ?? "unknown error"}`);
|
||||
err.code = data.error_code;
|
||||
throw err;
|
||||
}
|
||||
return data.result;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify the token and cache bot identity. */
|
||||
async getMe() {
|
||||
this.me = await this.call("getMe", {});
|
||||
return this.me;
|
||||
}
|
||||
|
||||
/** Set the slash-command menu shown when the user types "/". */
|
||||
async setMyCommands(commands) {
|
||||
await this.call("setMyCommands", { commands });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a text message, splitting into Telegram-sized chunks.
|
||||
* @returns {Promise<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));
|
||||
}
|
||||
1255
packages/telegram-remote/lib/commands.js
Normal file
1255
packages/telegram-remote/lib/commands.js
Normal file
File diff suppressed because it is too large
Load Diff
272
packages/telegram-remote/lib/features.js
Normal file
272
packages/telegram-remote/lib/features.js
Normal 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>";
|
||||
}
|
||||
663
packages/telegram-remote/lib/index.js
Normal file
663
packages/telegram-remote/lib/index.js
Normal 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 <id></code>"
|
||||
: "";
|
||||
await state.bot.send(
|
||||
chatId,
|
||||
"📷 <b>This model can't see images yet</b>" + modelLabel + "\n" +
|
||||
"Send your question as text instead, or switch to a vision-capable model first." + vision,
|
||||
);
|
||||
state.log("photo rejected: model " + (modelInfo.label ?? "?") + " has no image support");
|
||||
return;
|
||||
}
|
||||
|
||||
await callApi(state.ctx, "sessions", "prompt", {
|
||||
sessionId: cs.sessionId,
|
||||
mode: "queue",
|
||||
content,
|
||||
clientTimeZone: (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return "UTC"; } })(),
|
||||
});
|
||||
state.notePrompt(cs.sessionId, text);
|
||||
state.log("photo sent to " + cs.sessionId + " by " + userId);
|
||||
} catch (error) {
|
||||
state.log("photo handling failed: " + (error.stack ?? error.message));
|
||||
try {
|
||||
await state.bot.send(chatId, "⚠️ Couldn't send the photo: " + esc(error.message));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const AUDIO_EXT = {
|
||||
"audio/ogg": "ogg",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/mp3": "mp3",
|
||||
"audio/wav": "wav",
|
||||
"audio/x-wav": "wav",
|
||||
"audio/mp4": "m4a",
|
||||
"audio/aac": "m4a",
|
||||
"audio/x-m4a": "m4a",
|
||||
"audio/flac": "flac",
|
||||
};
|
||||
|
||||
/** Prefer Telegram's original file name; else derive one from the mime type. */
|
||||
function pickAudioFilename(audio, mimeType) {
|
||||
const name = String(audio.file_name ?? "").trim();
|
||||
if (name) return name;
|
||||
if (mimeType && /^audio\/ogg/i.test(String(mimeType).split(";")[0].trim())) return "voice.ogg";
|
||||
const ext = AUDIO_EXT[String(mimeType ?? "").split(";")[0].trim().toLowerCase()] ?? "bin";
|
||||
return "audio." + ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribe a voice message / audio file / audio document via the home-lab
|
||||
* Whishper server, show the transcript, and queue it into the chat session
|
||||
* like a normal user message.
|
||||
*/
|
||||
async function handleAudio(state, chatId, userId, audio, messageId) {
|
||||
let statusMessageId = null;
|
||||
try {
|
||||
const authorized = userId != null && state.isAuthorized(userId);
|
||||
if (!authorized) {
|
||||
try {
|
||||
await state.bot.send(chatId, "⛔ <b>Unauthorized</b>\nYour telegram user id: <code>" + (userId ?? "unknown") + "</code>");
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
const statusIds = await state.bot.send(chatId, "🎙️ Transcribing…");
|
||||
statusMessageId = statusIds[0];
|
||||
|
||||
const file = await state.bot.getFile(audio.file_id);
|
||||
const bytes = await state.bot.downloadFile(file.file_path);
|
||||
const mimeType = audio.mime_type ?? (audio.voice ? "audio/ogg" : "application/octet-stream");
|
||||
const filename = pickAudioFilename(audio, mimeType);
|
||||
|
||||
const text = await transcribeAudio(
|
||||
{
|
||||
baseUrl: state.config.whisperBaseUrl,
|
||||
model: state.config.whisperModel,
|
||||
device: state.config.whisperDevice,
|
||||
language: state.config.whisperLanguage,
|
||||
timeoutMs: state.config.whisperTimeoutMs,
|
||||
maxBytes: state.config.whisperMaxBytes,
|
||||
},
|
||||
bytes,
|
||||
filename,
|
||||
mimeType,
|
||||
);
|
||||
const trimmed = String(text ?? "").trim();
|
||||
if (!trimmed) throw new Error("Whisper returned an empty transcript");
|
||||
|
||||
// The transcription itself succeeded — a failed edit must not report an
|
||||
// error for a successful transcription.
|
||||
try {
|
||||
await state.bot.editMessageText(chatId, statusMessageId, "🎙️ " + esc(truncate(trimmed, 3500)));
|
||||
} catch (editError) {
|
||||
state.log("audio transcript edit failed: " + editError.message);
|
||||
}
|
||||
|
||||
const cs = state.chatState(chatId);
|
||||
if (messageId != null) cs.lastUserMessageId = messageId;
|
||||
if (!cs.sessionId) {
|
||||
try {
|
||||
const created = await callApi(state.ctx, "sessions", "create", { cwd: state.config.workspaceRoot || process.cwd() });
|
||||
cs.sessionId = created.sessionId;
|
||||
state.saveState();
|
||||
} catch {}
|
||||
}
|
||||
if (!cs.sessionId) {
|
||||
await state.bot.send(chatId, "No chat is open yet — tap 💬 New chat first.");
|
||||
return;
|
||||
}
|
||||
await callApi(state.ctx, "sessions", "prompt", {
|
||||
sessionId: cs.sessionId,
|
||||
mode: "queue",
|
||||
content: [{ type: "text", text: trimmed }],
|
||||
clientTimeZone: (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return "UTC"; } })(),
|
||||
});
|
||||
state.notePrompt(cs.sessionId, trimmed);
|
||||
state.log("audio transcribed (" + state.config.whisperModel + ") and sent to " + cs.sessionId + " by " + userId);
|
||||
} catch (error) {
|
||||
state.log("audio handling failed: " + (error.stack ?? error.message));
|
||||
const message = "⚠️ Couldn't transcribe the audio: " + esc(error.message);
|
||||
try {
|
||||
if (statusMessageId == null) throw new Error("no status message");
|
||||
await state.bot.editMessageText(chatId, statusMessageId, message);
|
||||
} catch {
|
||||
try { await state.bot.send(chatId, message); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ACTIVE model for a session and whether it supports image input.
|
||||
* The llm.models catalog does not carry inputModalities, so the decision asks
|
||||
* the LLM runtime directly — the same bridge-aware resolution the harness
|
||||
* admission uses: dsh-vision-bridge reports bridged models (e.g. deepseek-*)
|
||||
* as image-capable, because their images are converted to text before
|
||||
* dispatch. Only same-provider vision models are offered as switch targets.
|
||||
*/
|
||||
async function currentModelModalities(state, sessionId) {
|
||||
const ctx = state.ctx;
|
||||
let current = {};
|
||||
try {
|
||||
const r = await callApi(ctx, "sessions", "models", { sessionId });
|
||||
current = r?.current ?? {};
|
||||
} catch {}
|
||||
const def = ctx.get("agentDefaultModel")?.currentSelection?.() ?? {};
|
||||
const provider = current.provider ?? def.provider ?? "";
|
||||
const modelId = current.model ?? def.model ?? "";
|
||||
const label = (provider ? provider + "/" : "") + modelId;
|
||||
let imageSupported = false;
|
||||
let visionSuggestions = [];
|
||||
const llm = ctx.get("llm");
|
||||
if (llm && typeof llm.resolveModelInfo === "function") {
|
||||
try {
|
||||
const info = await llm.resolveModelInfo(provider, modelId);
|
||||
imageSupported = Array.isArray(info?.inputModalities) && info.inputModalities.includes("image");
|
||||
} catch {}
|
||||
}
|
||||
if (!imageSupported) {
|
||||
try {
|
||||
const catalog = await callApi(ctx, "llm", "models", {});
|
||||
const groups = Array.isArray(catalog) ? catalog : (catalog?.groups ?? []);
|
||||
for (const group of groups) {
|
||||
const groupId = group.group?.id ?? group.id ?? "";
|
||||
if (groupId !== provider) continue;
|
||||
const models = group.group?.models ?? group.models ?? [];
|
||||
for (const model of models) {
|
||||
if ((model.id ?? "") === modelId) continue;
|
||||
try {
|
||||
const info = await llm.resolveModelInfo(groupId, model.id);
|
||||
if (Array.isArray(info?.inputModalities) && info.inputModalities.includes("image")) {
|
||||
visionSuggestions.push(model.id);
|
||||
if (visionSuggestions.length >= 4) break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return { imageSupported, label, visionSuggestions };
|
||||
}
|
||||
|
||||
function resolveToken(config) {
|
||||
if (config.botToken) return config.botToken;
|
||||
if (config.tokenEnv) {
|
||||
const value = process.env[config.tokenEnv];
|
||||
if (value) return value;
|
||||
}
|
||||
if (config.tokenFile) {
|
||||
try {
|
||||
const value = readFileSync(config.tokenFile, "utf8").trim();
|
||||
if (value) return value;
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const value = readFileSync(join(dshHome(), "telegram-remote.token"), "utf8").trim();
|
||||
if (value) return value;
|
||||
} catch {}
|
||||
return "";
|
||||
}
|
||||
|
||||
function logBoot(ctx, message) {
|
||||
try { ctx.logger.warn("[telegram-remote] " + message); } catch {}
|
||||
try { appendFileSync(join(dshHome(), "telegram-remote.log"), "[" + nowIso() + "] " + message + "\n", "utf8"); } catch {}
|
||||
}
|
||||
|
||||
export default { name, inject, apply };
|
||||
327
packages/telegram-remote/lib/state.js
Normal file
327
packages/telegram-remote/lib/state.js
Normal 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);
|
||||
}
|
||||
}
|
||||
883
packages/telegram-remote/lib/stream.js
Normal file
883
packages/telegram-remote/lib/stream.js
Normal 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);
|
||||
}
|
||||
}
|
||||
57
packages/telegram-remote/lib/ui.js
Normal file
57
packages/telegram-remote/lib/ui.js
Normal 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,
|
||||
}))) };
|
||||
}
|
||||
202
packages/telegram-remote/lib/util.js
Normal file
202
packages/telegram-remote/lib/util.js
Normal 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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
const inline = (s) => s
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
|
||||
.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, "$1<i>$2</i>")
|
||||
.replace(/__([^_]+)__/g, "<u>$1</u>")
|
||||
.replace(/~~([^~]+)~~/g, "<s>$1</s>")
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2">$1</a>');
|
||||
const lines = String(text).split("\n");
|
||||
const out = [];
|
||||
let inFence = false;
|
||||
let fenceLang = "";
|
||||
const fence = [];
|
||||
const flushFence = () => {
|
||||
if (!inFence) return;
|
||||
inFence = false;
|
||||
const body = fence.join("\n");
|
||||
out.push((fenceLang ? "<i>" + escape(fenceLang) + "</i>\n" : "") + "<pre>" + escape(body) + "</pre>");
|
||||
fence.length = 0;
|
||||
fenceLang = "";
|
||||
};
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const fenceMatch = line.match(/^\s*```(.*)$/);
|
||||
if (fenceMatch) {
|
||||
if (!inFence) {
|
||||
inFence = true;
|
||||
fenceLang = fenceMatch[1].trim();
|
||||
} else {
|
||||
flushFence();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
fence.push(line);
|
||||
continue;
|
||||
}
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
out.push("");
|
||||
continue;
|
||||
}
|
||||
const h = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (h) {
|
||||
const prefix = h[1].length <= 2 ? "📌 " : h[1].length === 3 ? "▸ " : "• ";
|
||||
out.push("<b>" + prefix + inline(escape(h[2])) + "</b>");
|
||||
continue;
|
||||
}
|
||||
if (/^>\s?/.test(trimmed)) {
|
||||
out.push("<i>" + inline(escape(trimmed.replace(/^>\s?/, ""))) + "</i>");
|
||||
continue;
|
||||
}
|
||||
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
||||
out.push("──────────────");
|
||||
continue;
|
||||
}
|
||||
const ul = trimmed.match(/^([-*+])\s+(.+)$/);
|
||||
if (ul) {
|
||||
out.push("• " + inline(escape(ul[2])));
|
||||
continue;
|
||||
}
|
||||
const ol = trimmed.match(/^(\d+)[.)]\s+(.+)$/);
|
||||
if (ol) {
|
||||
out.push("<b>" + ol[1] + ".</b> " + inline(escape(ol[2])));
|
||||
continue;
|
||||
}
|
||||
if (trimmed.includes("-") && /^\|?[\s:-]+\|?\s*$/.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
out.push(inline(escape(trimmed)));
|
||||
}
|
||||
flushFence();
|
||||
const result = out.join("\n");
|
||||
if (htmlCache.size >= HTML_CACHE_MAX) htmlCache.delete(htmlCache.keys().next().value);
|
||||
htmlCache.set(text, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Extract readable text from a session content block array. */
|
||||
export function contentText(blocks, { textOnly = false } = {}) {
|
||||
if (!Array.isArray(blocks)) return "";
|
||||
return blocks
|
||||
.map((b) => {
|
||||
if (!b || typeof b !== "object") return "";
|
||||
if (b.type === "text" && typeof b.text === "string") return b.text;
|
||||
if (b.type === "reasoning") return "";
|
||||
if (b.type === "tool-call") {
|
||||
if (textOnly) return "";
|
||||
return "[" + b.name + "] " + truncate(String(b.arguments ?? ""), 120);
|
||||
}
|
||||
if (b.type === "tool-result") return contentText(b.content, { textOnly });
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** True when a content block array contains at least one real text block. */
|
||||
export function hasText(blocks) {
|
||||
if (!Array.isArray(blocks)) return false;
|
||||
return blocks.some((b) => b && typeof b === "object" && b.type === "text" && typeof b.text === "string" && b.text.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Gateway invocation with RPC-envelope unwrapping. */
|
||||
export async function invoke(gateway, namespace, method, args = {}) {
|
||||
const result = await gateway.invoke({ namespace, method, args });
|
||||
if (result && typeof result === "object" && "ok" in result) {
|
||||
if (result.ok === true) return result.value;
|
||||
const error = result.error ?? {};
|
||||
const err = new Error(error.message ?? namespace + "." + method + " failed");
|
||||
err.code = error.code;
|
||||
throw err;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call one harness API method the way the web client does, in-process.
|
||||
* Prefers the host api-proxy service (domains: sessions, subagents, host,
|
||||
* goals, workspace, skills, agentPresets, settings, credentials, llm, ...),
|
||||
* falling back to the typert gateway (slash namespaces such as commands/*,
|
||||
* pluginInventory/*, messageFeedback/*) when the api-proxy does not mount
|
||||
* that method. Returns the unwrapped business value or throws with the
|
||||
* wire error code attached.
|
||||
*/
|
||||
export async function callApi(ctx, domain, method, args = {}) {
|
||||
const apiProxy = ctx.get("apiProxy");
|
||||
if (apiProxy && typeof apiProxy?.[domain]?.[method] === "function") {
|
||||
const response = await apiProxy[domain][method]({
|
||||
rpcId: "tg-" + Math.random().toString(36).slice(2, 10),
|
||||
payload: args,
|
||||
});
|
||||
const result = response?.result;
|
||||
if (result && typeof result === "object") {
|
||||
if (result.ok === true) return result.value;
|
||||
if (result.ok === false) {
|
||||
const error = result.error ?? {};
|
||||
const err = new Error(error.message ?? domain + "." + method + " failed");
|
||||
err.code = error.code;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const gateway = ctx.get("typertGateway");
|
||||
if (gateway) return invoke(gateway, domain, method, args);
|
||||
throw new Error("no api surface for " + domain + "." + method);
|
||||
}
|
||||
85
packages/telegram-remote/lib/whisper.js
Normal file
85
packages/telegram-remote/lib/whisper.js
Normal 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) + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user