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