diff --git a/index.ts b/index.ts index cff7123..e862f9f 100644 --- a/index.ts +++ b/index.ts @@ -2,12 +2,13 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { basename, extname, join } from "node:path"; import { homedir } from "node:os"; -import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; +import type { ImageContent, Model, TextContent } from "@mariozechner/pi-ai"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ExtensionAPI, ExtensionContext, } from "@mariozechner/pi-coding-agent"; +import { SettingsManager } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; interface TelegramConfig { @@ -104,10 +105,18 @@ interface TelegramMessage { sticker?: TelegramSticker; } +interface TelegramCallbackQuery { + id: string; + from: TelegramUser; + message?: TelegramMessage; + data?: string; +} + interface TelegramUpdate { update_id: number; message?: TelegramMessage; edited_message?: TelegramMessage; + callback_query?: TelegramCallbackQuery; } interface TelegramGetFileResult { @@ -154,6 +163,25 @@ interface TelegramMediaGroupState { flushTimer?: ReturnType; } +type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; +type TelegramModelScope = "all" | "scoped"; + +interface ScopedTelegramModel { + model: Model; + thinkingLevel?: ThinkingLevel; +} + +interface TelegramModelMenuState { + chatId: number; + messageId: number; + page: number; + scope: TelegramModelScope; + scopedModels: ScopedTelegramModel[]; + allModels: ScopedTelegramModel[]; + note?: string; + mode: "status" | "model" | "thinking"; +} + const CONFIG_PATH = join(homedir(), ".pi", "agent", "telegram.json"); const TEMP_DIR = join(homedir(), ".pi", "agent", "tmp", "telegram"); const TELEGRAM_PREFIX = "[telegram]"; @@ -162,6 +190,15 @@ const MAX_ATTACHMENTS_PER_TURN = 10; const PREVIEW_THROTTLE_MS = 750; const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647; const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200; +const TELEGRAM_MODEL_PAGE_SIZE = 6; +const THINKING_LEVELS: readonly ThinkingLevel[] = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", +]; const SYSTEM_PROMPT_SUFFIX = ` @@ -218,6 +255,278 @@ function formatTokens(count: number): string { return `${Math.round(count / 1000000)}M`; } +function modelsMatch( + a: Model | undefined, + b: Model | undefined, +): boolean { + return !!a && !!b && a.provider === b.provider && a.id === b.id; +} + +function getCanonicalModelId(model: Model): string { + return `${model.provider}/${model.id}`; +} + +function isThinkingLevel(value: string): value is ThinkingLevel { + return THINKING_LEVELS.includes(value as ThinkingLevel); +} + +function escapeRegex(text: string): string { + return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function globMatches(text: string, pattern: string): boolean { + let regex = "^"; + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i]; + if (char === "*") { + regex += ".*"; + continue; + } + if (char === "?") { + regex += "."; + continue; + } + if (char === "[") { + const end = pattern.indexOf("]", i + 1); + if (end !== -1) { + const content = pattern.slice(i + 1, end); + if (content.startsWith("!")) { + regex += `[^${content.slice(1)}]`; + } else { + regex += `[${content}]`; + } + i = end; + continue; + } + } + regex += escapeRegex(char); + } + regex += "$"; + return new RegExp(regex, "i").test(text); +} + +function isAliasModelId(id: string): boolean { + if (id.endsWith("-latest")) return true; + return !/-\d{8}$/.test(id); +} + +function findExactModelReferenceMatch( + modelReference: string, + availableModels: Model[], +): Model | undefined { + const trimmedReference = modelReference.trim(); + if (!trimmedReference) return undefined; + const normalizedReference = trimmedReference.toLowerCase(); + const canonicalMatches = availableModels.filter( + (model) => getCanonicalModelId(model).toLowerCase() === normalizedReference, + ); + if (canonicalMatches.length === 1) return canonicalMatches[0]; + if (canonicalMatches.length > 1) return undefined; + const slashIndex = trimmedReference.indexOf("/"); + if (slashIndex !== -1) { + const provider = trimmedReference.substring(0, slashIndex).trim(); + const modelId = trimmedReference.substring(slashIndex + 1).trim(); + if (provider && modelId) { + const providerMatches = availableModels.filter( + (model) => + model.provider.toLowerCase() === provider.toLowerCase() && + model.id.toLowerCase() === modelId.toLowerCase(), + ); + if (providerMatches.length === 1) return providerMatches[0]; + if (providerMatches.length > 1) return undefined; + } + } + const idMatches = availableModels.filter( + (model) => model.id.toLowerCase() === normalizedReference, + ); + return idMatches.length === 1 ? idMatches[0] : undefined; +} + +function tryMatchScopedModel( + modelPattern: string, + availableModels: Model[], +): Model | undefined { + const exactMatch = findExactModelReferenceMatch( + modelPattern, + availableModels, + ); + if (exactMatch) return exactMatch; + const matches = availableModels.filter( + (model) => + model.id.toLowerCase().includes(modelPattern.toLowerCase()) || + model.name?.toLowerCase().includes(modelPattern.toLowerCase()), + ); + if (matches.length === 0) return undefined; + const aliases = matches.filter((model) => isAliasModelId(model.id)); + const datedVersions = matches.filter((model) => !isAliasModelId(model.id)); + if (aliases.length > 0) { + aliases.sort((a, b) => b.id.localeCompare(a.id)); + return aliases[0]; + } + datedVersions.sort((a, b) => b.id.localeCompare(a.id)); + return datedVersions[0]; +} + +function parseScopedModelPattern( + pattern: string, + availableModels: Model[], +): { model: Model | undefined; thinkingLevel?: ThinkingLevel } { + const exactMatch = tryMatchScopedModel(pattern, availableModels); + if (exactMatch) { + return { model: exactMatch, thinkingLevel: undefined }; + } + const lastColonIndex = pattern.lastIndexOf(":"); + if (lastColonIndex === -1) { + return { model: undefined, thinkingLevel: undefined }; + } + const prefix = pattern.substring(0, lastColonIndex); + const suffix = pattern.substring(lastColonIndex + 1); + if (isThinkingLevel(suffix)) { + const result = parseScopedModelPattern(prefix, availableModels); + if (result.model) { + return { model: result.model, thinkingLevel: suffix }; + } + return result; + } + return parseScopedModelPattern(prefix, availableModels); +} + +function resolveScopedModelPatterns( + patterns: string[], + availableModels: Model[], +): ScopedTelegramModel[] { + const resolved: ScopedTelegramModel[] = []; + const seen = new Set(); + for (const pattern of patterns) { + if ( + pattern.includes("*") || + pattern.includes("?") || + pattern.includes("[") + ) { + const colonIndex = pattern.lastIndexOf(":"); + let globPattern = pattern; + let thinkingLevel: ThinkingLevel | undefined; + if (colonIndex !== -1) { + const suffix = pattern.substring(colonIndex + 1); + if (isThinkingLevel(suffix)) { + thinkingLevel = suffix; + globPattern = pattern.substring(0, colonIndex); + } + } + const matches = availableModels.filter( + (model) => + globMatches(getCanonicalModelId(model), globPattern) || + globMatches(model.id, globPattern), + ); + for (const model of matches) { + const key = getCanonicalModelId(model); + if (seen.has(key)) continue; + seen.add(key); + resolved.push({ model, thinkingLevel }); + } + continue; + } + const matched = parseScopedModelPattern(pattern, availableModels); + if (!matched.model) continue; + const key = getCanonicalModelId(matched.model); + if (seen.has(key)) continue; + seen.add(key); + resolved.push({ + model: matched.model, + thinkingLevel: matched.thinkingLevel, + }); + } + return resolved; +} + +function sortScopedModels( + models: ScopedTelegramModel[], + currentModel: Model | undefined, +): ScopedTelegramModel[] { + const sorted = [...models]; + sorted.sort((a, b) => { + const aIsCurrent = modelsMatch(a.model, currentModel); + const bIsCurrent = modelsMatch(b.model, currentModel); + if (aIsCurrent && !bIsCurrent) return -1; + if (!aIsCurrent && bIsCurrent) return 1; + const providerCompare = a.model.provider.localeCompare(b.model.provider); + if (providerCompare !== 0) return providerCompare; + return a.model.id.localeCompare(b.model.id); + }); + return sorted; +} + +function parseTelegramCommand( + text: string, +): { name: string; args: string } | undefined { + const trimmed = text.trim(); + if (!trimmed.startsWith("/")) return undefined; + const [head, ...tail] = trimmed.split(/\s+/); + const name = head.slice(1).split("@")[0]?.toLowerCase(); + if (!name) return undefined; + return { name, args: tail.join(" ").trim() }; +} + +function getCliScopedModelPatterns(): string[] | undefined { + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--models") { + const value = args[i + 1] ?? ""; + const patterns = value + .split(",") + .map((pattern) => pattern.trim()) + .filter(Boolean); + return patterns.length > 0 ? patterns : undefined; + } + if (arg.startsWith("--models=")) { + const patterns = arg + .slice("--models=".length) + .split(",") + .map((pattern) => pattern.trim()) + .filter(Boolean); + return patterns.length > 0 ? patterns : undefined; + } + } + return undefined; +} + +function truncateTelegramButtonLabel(label: string, maxLength = 56): string { + return label.length <= maxLength + ? label + : `${label.slice(0, maxLength - 1)}…`; +} + +function formatScopedModelButtonText( + entry: ScopedTelegramModel, + currentModel: Model | undefined, +): string { + let label = `${modelsMatch(entry.model, currentModel) ? "✅ " : ""}${entry.model.id} [${entry.model.provider}]`; + if (entry.thinkingLevel) { + label += ` · ${entry.thinkingLevel}`; + } + return truncateTelegramButtonLabel(label); +} + +function formatStatusButtonLabel(label: string, value: string): string { + return truncateTelegramButtonLabel(`${label}: ${value}`, 64); +} + +function getModelMenuItems( + state: TelegramModelMenuState, +): ScopedTelegramModel[] { + return state.scope === "scoped" && state.scopedModels.length > 0 + ? state.scopedModels + : state.allModels; +} + function chunkParagraphs(text: string): string[] { if (text.length <= MAX_MESSAGE_LENGTH) return [text]; @@ -308,7 +617,9 @@ export default function (pi: ExtensionAPI) { let previewState: TelegramPreviewState | undefined; let draftSupport: "unknown" | "supported" | "unsupported" = "unknown"; let nextDraftId = 0; + let currentTelegramModel: Model | undefined; const mediaGroups = new Map(); + const modelMenus = new Map(); function allocateDraftId(): number { nextDraftId = nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : nextDraftId + 1; @@ -590,7 +901,16 @@ export default function (pi: ExtensionAPI) { chatId: number, _replyToMessageId: number, text: string, + options?: { parseMode?: "HTML" }, ): Promise { + if (options?.parseMode) { + const sent = await callTelegram("sendMessage", { + chat_id: chatId, + text, + parse_mode: options.parseMode, + }); + return sent.message_id; + } const chunks = chunkParagraphs(text); let lastMessageId: number | undefined; for (const chunk of chunks) { @@ -810,6 +1130,490 @@ export default function (pi: ExtensionAPI) { pollingPromise = undefined; } + async function answerCallbackQuery( + callbackQueryId: string, + text?: string, + ): Promise { + try { + await callTelegram( + "answerCallbackQuery", + text + ? { callback_query_id: callbackQueryId, text } + : { callback_query_id: callbackQueryId }, + ); + } catch { + // ignore + } + } + + function getCurrentTelegramModel( + ctx: ExtensionContext, + ): Model | undefined { + return currentTelegramModel ?? ctx.model; + } + + async function getModelMenuState( + chatId: number, + ctx: ExtensionContext, + ): Promise { + const settingsManager = SettingsManager.create(ctx.cwd); + await settingsManager.reload(); + ctx.modelRegistry.refresh(); + const activeModel = getCurrentTelegramModel(ctx); + const availableModels = ctx.modelRegistry.getAvailable(); + const allModels = sortScopedModels( + availableModels.map((model) => ({ model })), + activeModel, + ); + const cliScopedModels = getCliScopedModelPatterns(); + const configuredScopedModels = + cliScopedModels ?? settingsManager.getEnabledModels() ?? []; + const scopedModels = + configuredScopedModels.length > 0 + ? sortScopedModels( + resolveScopedModelPatterns(configuredScopedModels, availableModels), + activeModel, + ) + : []; + let note: string | undefined; + if (configuredScopedModels.length > 0 && scopedModels.length === 0) { + note = cliScopedModels + ? "No CLI scoped models matched the current auth configuration. Showing all available models." + : "No scoped models matched the current auth configuration. Showing all available models."; + } + return { + chatId, + messageId: 0, + page: 0, + scope: scopedModels.length > 0 ? "scoped" : "all", + scopedModels, + allModels, + note, + mode: "status", + }; + } + + function buildModelMenuText( + state: TelegramModelMenuState, + currentModel: Model | undefined, + ): string { + const items = getModelMenuItems(state); + const pageCount = Math.max( + 1, + Math.ceil(items.length / TELEGRAM_MODEL_PAGE_SIZE), + ); + const page = Math.min(state.page, pageCount - 1) + 1; + const scopeLabel = + state.scope === "scoped" && state.scopedModels.length > 0 + ? "scoped" + : "all available"; + const lines = ["Choose a model"]; + if (currentModel) { + lines.push(`Current: ${getCanonicalModelId(currentModel)}`); + } + lines.push(`Scope: ${scopeLabel}`); + lines.push(`Page: ${page}/${pageCount}`); + if (state.note) { + lines.push("", state.note); + } + return lines.join("\n"); + } + + function buildThinkingMenuText(ctx: ExtensionContext): string { + const activeModel = getCurrentTelegramModel(ctx); + const lines = ["Choose a thinking level"]; + if (activeModel) { + lines.push(`Model: ${getCanonicalModelId(activeModel)}`); + } + lines.push(`Current: ${pi.getThinkingLevel()}`); + return lines.join("\n"); + } + + function buildModelMenuReplyMarkup( + state: TelegramModelMenuState, + currentModel: Model | undefined, + ): { + inline_keyboard: Array>; + } { + const items = getModelMenuItems(state); + const pageCount = Math.max( + 1, + Math.ceil(items.length / TELEGRAM_MODEL_PAGE_SIZE), + ); + state.page = Math.max(0, Math.min(state.page, pageCount - 1)); + const start = state.page * TELEGRAM_MODEL_PAGE_SIZE; + const pageItems = items.slice(start, start + TELEGRAM_MODEL_PAGE_SIZE); + const rows = pageItems.map((entry, index) => [ + { + text: formatScopedModelButtonText(entry, currentModel), + callback_data: `model:pick:${start + index}`, + }, + ]); + if (pageCount > 1) { + const previousPage = state.page === 0 ? pageCount - 1 : state.page - 1; + const nextPage = state.page === pageCount - 1 ? 0 : state.page + 1; + rows.push([ + { text: "⬅️", callback_data: `model:page:${previousPage}` }, + { text: `${state.page + 1}/${pageCount}`, callback_data: "model:noop" }, + { text: "➡️", callback_data: `model:page:${nextPage}` }, + ]); + } + if (state.scopedModels.length > 0) { + rows.push([ + { + text: state.scope === "scoped" ? "✅ Scoped" : "Scoped", + callback_data: "model:scope:scoped", + }, + { + text: state.scope === "all" ? "✅ All" : "All", + callback_data: "model:scope:all", + }, + ]); + } + return { inline_keyboard: rows }; + } + + function buildThinkingMenuReplyMarkup(ctx: ExtensionContext): { + inline_keyboard: Array>; + } { + const currentThinkingLevel = pi.getThinkingLevel(); + return { + inline_keyboard: THINKING_LEVELS.map((level) => [ + { + text: level === currentThinkingLevel ? `✅ ${level}` : level, + callback_data: `thinking:set:${level}`, + }, + ]), + }; + } + + function buildStatusReplyMarkup(ctx: ExtensionContext): { + inline_keyboard: Array>; + } { + const activeModel = getCurrentTelegramModel(ctx); + const rows: Array> = []; + rows.push([ + { + text: formatStatusButtonLabel( + "Model", + activeModel ? getCanonicalModelId(activeModel) : "unknown", + ), + callback_data: "status:model", + }, + ]); + if (activeModel?.reasoning) { + rows.push([ + { + text: formatStatusButtonLabel("Thinking", pi.getThinkingLevel()), + callback_data: "status:thinking", + }, + ]); + } + return { inline_keyboard: rows }; + } + + async function updateModelMenuMessage( + state: TelegramModelMenuState, + ctx: ExtensionContext, + ): Promise { + state.mode = "model"; + const activeModel = getCurrentTelegramModel(ctx); + await callTelegram("editMessageText", { + chat_id: state.chatId, + message_id: state.messageId, + text: buildModelMenuText(state, activeModel), + reply_markup: buildModelMenuReplyMarkup(state, activeModel), + }); + } + + async function updateThinkingMenuMessage( + state: TelegramModelMenuState, + ctx: ExtensionContext, + ): Promise { + state.mode = "thinking"; + await callTelegram("editMessageText", { + chat_id: state.chatId, + message_id: state.messageId, + text: buildThinkingMenuText(ctx), + reply_markup: buildThinkingMenuReplyMarkup(ctx), + }); + } + + async function showStatusMessage( + state: TelegramModelMenuState, + ctx: ExtensionContext, + ): Promise { + state.mode = "status"; + await callTelegram("editMessageText", { + chat_id: state.chatId, + message_id: state.messageId, + text: buildStatusHtml(ctx), + parse_mode: "HTML", + reply_markup: buildStatusReplyMarkup(ctx), + }); + } + + async function sendStatusMessage( + chatId: number, + replyToMessageId: number, + ctx: ExtensionContext, + ): Promise { + if (!ctx.isIdle()) { + await sendTextReply( + chatId, + replyToMessageId, + 'Cannot open status while pi is busy. Send "stop" first.', + ); + return; + } + const state = await getModelMenuState(chatId, ctx); + const sent = await callTelegram("sendMessage", { + chat_id: chatId, + text: buildStatusHtml(ctx), + parse_mode: "HTML", + reply_to_message_id: replyToMessageId, + reply_markup: buildStatusReplyMarkup(ctx), + }); + state.messageId = sent.message_id; + state.mode = "status"; + modelMenus.set(sent.message_id, state); + } + + async function openModelMenu( + chatId: number, + replyToMessageId: number, + ctx: ExtensionContext, + ): Promise { + if (!ctx.isIdle()) { + await sendTextReply( + chatId, + replyToMessageId, + 'Cannot switch model while pi is busy. Send "stop" first.', + ); + return; + } + const state = await getModelMenuState(chatId, ctx); + if (state.allModels.length === 0) { + await sendTextReply( + chatId, + replyToMessageId, + "No available models with configured auth.", + ); + return; + } + const activeModel = getCurrentTelegramModel(ctx); + const sent = await callTelegram("sendMessage", { + chat_id: chatId, + text: buildModelMenuText(state, activeModel), + reply_to_message_id: replyToMessageId, + reply_markup: buildModelMenuReplyMarkup(state, activeModel), + }); + state.messageId = sent.message_id; + state.mode = "model"; + modelMenus.set(sent.message_id, state); + } + + async function handleAuthorizedTelegramCallbackQuery( + query: TelegramCallbackQuery, + ctx: ExtensionContext, + ): Promise { + const messageId = query.message?.message_id; + if (!messageId || !query.data) { + await answerCallbackQuery(query.id); + return; + } + const state = modelMenus.get(messageId); + if (!state) { + await answerCallbackQuery(query.id, "Interactive message expired."); + return; + } + if (query.data === "status:model") { + await updateModelMenuMessage(state, ctx); + await answerCallbackQuery(query.id); + return; + } + if (query.data === "status:thinking") { + const activeModel = getCurrentTelegramModel(ctx); + if (!activeModel?.reasoning) { + await answerCallbackQuery( + query.id, + "This model has no reasoning controls.", + ); + return; + } + await updateThinkingMenuMessage(state, ctx); + await answerCallbackQuery(query.id); + return; + } + if (query.data.startsWith("thinking:set:")) { + const level = query.data.slice("thinking:set:".length); + if (!isThinkingLevel(level)) { + await answerCallbackQuery(query.id, "Invalid thinking level."); + return; + } + const activeModel = getCurrentTelegramModel(ctx); + if (!activeModel?.reasoning) { + await answerCallbackQuery( + query.id, + "This model has no reasoning controls.", + ); + return; + } + pi.setThinkingLevel(level); + await showStatusMessage(state, ctx); + await answerCallbackQuery(query.id, `Thinking: ${pi.getThinkingLevel()}`); + return; + } + if (!query.data.startsWith("model:")) { + await answerCallbackQuery(query.id); + return; + } + const [, action, value] = query.data.split(":"); + if (action === "noop") { + await answerCallbackQuery(query.id); + return; + } + if (action === "scope") { + if (value !== "all" && value !== "scoped") { + await answerCallbackQuery(query.id, "Unknown model scope."); + return; + } + if (value === state.scope) { + await answerCallbackQuery(query.id); + return; + } + state.scope = value; + state.page = 0; + await updateModelMenuMessage(state, ctx); + await answerCallbackQuery( + query.id, + state.scope === "scoped" ? "Scoped models" : "All models", + ); + return; + } + if (action === "page") { + const page = Number(value); + if (!Number.isFinite(page)) { + await answerCallbackQuery(query.id, "Invalid page."); + return; + } + if (page === state.page) { + await answerCallbackQuery(query.id); + return; + } + state.page = page; + await updateModelMenuMessage(state, ctx); + await answerCallbackQuery(query.id); + return; + } + if (action === "pick") { + const index = Number(value); + if (!Number.isFinite(index)) { + await answerCallbackQuery(query.id, "Invalid model selection."); + return; + } + const items = getModelMenuItems(state); + const selection = items[index]; + if (!selection) { + await answerCallbackQuery( + query.id, + "Selected model is no longer available.", + ); + return; + } + if (!ctx.isIdle()) { + await answerCallbackQuery(query.id, 'Pi is busy. Send "stop" first.'); + return; + } + const activeModel = getCurrentTelegramModel(ctx); + if (modelsMatch(selection.model, activeModel)) { + if ( + selection.thinkingLevel && + selection.thinkingLevel !== pi.getThinkingLevel() + ) { + pi.setThinkingLevel(selection.thinkingLevel); + } + await showStatusMessage(state, ctx); + await answerCallbackQuery(query.id, `Model: ${selection.model.id}`); + return; + } + try { + const changed = await pi.setModel(selection.model); + if (changed === false) { + await answerCallbackQuery(query.id, "Model is not available."); + return; + } + currentTelegramModel = selection.model; + if (selection.thinkingLevel) { + pi.setThinkingLevel(selection.thinkingLevel); + } + await showStatusMessage(state, ctx); + await answerCallbackQuery( + query.id, + `Switched to ${selection.model.id}`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await answerCallbackQuery(query.id, message); + } + return; + } + await answerCallbackQuery(query.id); + } + + function buildStatusHtml(ctx: ExtensionContext): string { + let totalInput = 0; + let totalOutput = 0; + let totalCacheRead = 0; + let totalCacheWrite = 0; + let totalCost = 0; + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type !== "message" || entry.message.role !== "assistant") + continue; + totalInput += entry.message.usage.input; + totalOutput += entry.message.usage.output; + totalCacheRead += entry.message.usage.cacheRead; + totalCacheWrite += entry.message.usage.cacheWrite; + totalCost += entry.message.usage.cost.total; + } + const activeModel = getCurrentTelegramModel(ctx); + const usage = ctx.getContextUsage(); + const lines: string[] = []; + const tokenParts: string[] = []; + if (totalInput) tokenParts.push(`↑${formatTokens(totalInput)}`); + if (totalOutput) tokenParts.push(`↓${formatTokens(totalOutput)}`); + if (totalCacheRead) tokenParts.push(`R${formatTokens(totalCacheRead)}`); + if (totalCacheWrite) tokenParts.push(`W${formatTokens(totalCacheWrite)}`); + if (tokenParts.length > 0) { + lines.push( + `Usage: ${escapeHtml(tokenParts.join(" "))}`, + ); + } + const usingSubscription = activeModel + ? ctx.modelRegistry.isUsingOAuth(activeModel) + : false; + if (totalCost || usingSubscription) { + lines.push( + `Cost: ${escapeHtml(`$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`)}`, + ); + } + if (usage) { + const contextWindow = + usage.contextWindow ?? activeModel?.contextWindow ?? 0; + const percent = + usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "?"; + lines.push( + `Context: ${escapeHtml(`${percent}/${formatTokens(contextWindow)}`)}`, + ); + } else { + lines.push(`Context: unknown`); + } + if (lines.length === 0) { + lines.push(`Status: No usage data yet.`); + } + return lines.join("\n"); + } + function formatTelegramHistoryText( rawText: string, files: DownloadedTelegramFile[], @@ -890,8 +1694,10 @@ export default function (pi: ExtensionAPI) { .map((message) => (message.text || message.caption || "").trim()) .find((text) => text.length > 0) || ""; const lower = rawText.toLowerCase(); + const command = parseTelegramCommand(rawText); + const commandName = command?.name; - if (lower === "stop" || lower === "/stop") { + if (lower === "stop" || commandName === "stop") { if (currentAbort) { if (queuedTelegramTurns.length > 0) { preserveQueuedTurnsAsHistory = true; @@ -913,7 +1719,7 @@ export default function (pi: ExtensionAPI) { return; } - if (lower === "/compact") { + if (commandName === "compact") { if (!ctx.isIdle()) { await sendTextReply( firstMessage.chat.id, @@ -948,69 +1754,25 @@ export default function (pi: ExtensionAPI) { return; } - if (lower === "/status") { - let totalInput = 0; - let totalOutput = 0; - let totalCacheRead = 0; - let totalCacheWrite = 0; - let totalCost = 0; - - for (const entry of ctx.sessionManager.getEntries()) { - if (entry.type !== "message" || entry.message.role !== "assistant") - continue; - totalInput += entry.message.usage.input; - totalOutput += entry.message.usage.output; - totalCacheRead += entry.message.usage.cacheRead; - totalCacheWrite += entry.message.usage.cacheWrite; - totalCost += entry.message.usage.cost.total; - } - - const usage = ctx.getContextUsage(); - const lines: string[] = []; - if (ctx.model) { - lines.push(`Model: ${ctx.model.provider}/${ctx.model.id}`); - } - const tokenParts: string[] = []; - if (totalInput) tokenParts.push(`↑${formatTokens(totalInput)}`); - if (totalOutput) tokenParts.push(`↓${formatTokens(totalOutput)}`); - if (totalCacheRead) tokenParts.push(`R${formatTokens(totalCacheRead)}`); - if (totalCacheWrite) tokenParts.push(`W${formatTokens(totalCacheWrite)}`); - if (tokenParts.length > 0) { - lines.push(`Usage: ${tokenParts.join(" ")}`); - } - const usingSubscription = ctx.model - ? ctx.modelRegistry.isUsingOAuth(ctx.model) - : false; - if (totalCost || usingSubscription) { - lines.push( - `Cost: $${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`, - ); - } - if (usage) { - const contextWindow = - usage.contextWindow ?? ctx.model?.contextWindow ?? 0; - const percent = - usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "?"; - lines.push(`Context: ${percent}/${formatTokens(contextWindow)}`); - } else { - lines.push("Context: unknown"); - } - if (lines.length === 0) { - lines.push("No usage data yet."); - } - await sendTextReply( + if (commandName === "status") { + await sendStatusMessage( firstMessage.chat.id, firstMessage.message_id, - lines.join("\n"), + ctx, ); return; } - if (lower === "/help" || lower === "/start") { + if (commandName === "model") { + await openModelMenu(firstMessage.chat.id, firstMessage.message_id, ctx); + return; + } + + if (commandName === "help" || commandName === "start") { await sendTextReply( firstMessage.chat.id, firstMessage.message_id, - `Send me a message and I will forward it to pi. Commands: /status, /compact, stop.`, + `Send me a message and I will forward it to pi. Commands: /status, /model, /compact, stop.`, ); if (config.allowedUserId === undefined && firstMessage.from) { config.allowedUserId = firstMessage.from.id; @@ -1059,6 +1821,31 @@ export default function (pi: ExtensionAPI) { update: TelegramUpdate, ctx: ExtensionContext, ): Promise { + if (update.callback_query) { + const query = update.callback_query; + const message = query.message; + if ( + !message || + message.chat.type !== "private" || + !query.from || + query.from.is_bot + ) + return; + if (config.allowedUserId === undefined) { + config.allowedUserId = query.from.id; + await writeConfig(config); + updateStatus(ctx); + } + if (query.from.id !== config.allowedUserId) { + await answerCallbackQuery( + query.id, + "This bot is not authorized for your account.", + ); + return; + } + await handleAuthorizedTelegramCallbackQuery(query, ctx); + return; + } const message = update.message || update.edited_message; if ( !message || @@ -1067,7 +1854,6 @@ export default function (pi: ExtensionAPI) { message.from.is_bot ) return; - if (config.allowedUserId === undefined) { config.allowedUserId = message.from.id; await writeConfig(config); @@ -1078,7 +1864,6 @@ export default function (pi: ExtensionAPI) { "Telegram bridge paired with this account.", ); } - if (message.from.id !== config.allowedUserId) { await sendTextReply( message.chat.id, @@ -1087,7 +1872,6 @@ export default function (pi: ExtensionAPI) { ); return; } - await handleAuthorizedTelegramMessage(message, ctx); } @@ -1135,7 +1919,7 @@ export default function (pi: ExtensionAPI) { : undefined, limit: 10, timeout: 30, - allowed_updates: ["message", "edited_message"], + allowed_updates: ["message", "edited_message", "callback_query"], }, { signal }, ); @@ -1264,16 +2048,19 @@ export default function (pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { config = await readConfig(); + currentTelegramModel = ctx.model; await mkdir(TEMP_DIR, { recursive: true }); updateStatus(ctx); }); pi.on("session_shutdown", async (_event, _ctx) => { queuedTelegramTurns = []; + currentTelegramModel = undefined; for (const state of mediaGroups.values()) { if (state.flushTimer) clearTimeout(state.flushTimer); } mediaGroups.clear(); + modelMenus.clear(); if (activeTelegramTurn) { await clearPreview(activeTelegramTurn.chatId); } @@ -1292,6 +2079,10 @@ export default function (pi: ExtensionAPI) { }; }); + pi.on("model_select", async (event) => { + currentTelegramModel = event.model; + }); + pi.on("agent_start", async (_event, ctx) => { currentAbort = () => ctx.abort(); if (!activeTelegramTurn && queuedTelegramTurns.length > 0) {