mirror of
https://github.com/wassname/pi-telegram.git
synced 2026-06-27 16:16:14 +08:00
0.2.0: refactor into domain modules
This commit is contained in:
+222
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Telegram API and config persistence helpers
|
||||
* Wraps bot API calls, file downloads, and local config reads and writes for the bridge runtime
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface TelegramConfig {
|
||||
botToken?: string;
|
||||
botUsername?: string;
|
||||
botId?: number;
|
||||
allowedUserId?: number;
|
||||
lastUpdateId?: number;
|
||||
}
|
||||
|
||||
interface TelegramApiResponse<T> {
|
||||
ok: boolean;
|
||||
result?: T;
|
||||
description?: string;
|
||||
error_code?: number;
|
||||
}
|
||||
|
||||
interface TelegramGetFileResult {
|
||||
file_path: string;
|
||||
}
|
||||
|
||||
export interface TelegramApiClient {
|
||||
call: <TResponse>(
|
||||
method: string,
|
||||
body: Record<string, unknown>,
|
||||
options?: { signal?: AbortSignal },
|
||||
) => Promise<TResponse>;
|
||||
callMultipart: <TResponse>(
|
||||
method: string,
|
||||
fields: Record<string, string>,
|
||||
fileField: string,
|
||||
filePath: string,
|
||||
fileName: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
) => Promise<TResponse>;
|
||||
downloadFile: (
|
||||
fileId: string,
|
||||
suggestedName: string,
|
||||
tempDir: string,
|
||||
) => Promise<string>;
|
||||
answerCallbackQuery: (
|
||||
callbackQueryId: string,
|
||||
text?: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
||||
}
|
||||
|
||||
export async function readTelegramConfig(
|
||||
configPath: string,
|
||||
): Promise<TelegramConfig> {
|
||||
try {
|
||||
const content = await readFile(configPath, "utf8");
|
||||
return JSON.parse(content) as TelegramConfig;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeTelegramConfig(
|
||||
agentDir: string,
|
||||
configPath: string,
|
||||
config: TelegramConfig,
|
||||
): Promise<void> {
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify(config, null, "\t") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export async function callTelegram<TResponse>(
|
||||
botToken: string | undefined,
|
||||
method: string,
|
||||
body: Record<string, unknown>,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<TResponse> {
|
||||
if (!botToken) {
|
||||
throw new Error("Telegram bot token is not configured");
|
||||
}
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${botToken}/${method}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal,
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as TelegramApiResponse<TResponse>;
|
||||
if (!data.ok || data.result === undefined) {
|
||||
throw new Error(data.description || `Telegram API ${method} failed`);
|
||||
}
|
||||
return data.result;
|
||||
}
|
||||
|
||||
export async function callTelegramMultipart<TResponse>(
|
||||
botToken: string | undefined,
|
||||
method: string,
|
||||
fields: Record<string, string>,
|
||||
fileField: string,
|
||||
filePath: string,
|
||||
fileName: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<TResponse> {
|
||||
if (!botToken) {
|
||||
throw new Error("Telegram bot token is not configured");
|
||||
}
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
form.set(key, value);
|
||||
}
|
||||
const buffer = await readFile(filePath);
|
||||
form.set(fileField, new Blob([buffer]), fileName);
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${botToken}/${method}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: options?.signal,
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as TelegramApiResponse<TResponse>;
|
||||
if (!data.ok || data.result === undefined) {
|
||||
throw new Error(data.description || `Telegram API ${method} failed`);
|
||||
}
|
||||
return data.result;
|
||||
}
|
||||
|
||||
export async function downloadTelegramFile(
|
||||
botToken: string | undefined,
|
||||
fileId: string,
|
||||
suggestedName: string,
|
||||
tempDir: string,
|
||||
): Promise<string> {
|
||||
if (!botToken) {
|
||||
throw new Error("Telegram bot token is not configured");
|
||||
}
|
||||
const file = await callTelegram<TelegramGetFileResult>(botToken, "getFile", {
|
||||
file_id: fileId,
|
||||
});
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
const targetPath = join(
|
||||
tempDir,
|
||||
`${Date.now()}-${sanitizeFileName(suggestedName)}`,
|
||||
);
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/file/bot${botToken}/${file.file_path}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download Telegram file: ${response.status}`);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
await writeFile(targetPath, Buffer.from(arrayBuffer));
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export async function answerTelegramCallbackQuery(
|
||||
botToken: string | undefined,
|
||||
callbackQueryId: string,
|
||||
text?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await callTelegram<boolean>(
|
||||
botToken,
|
||||
"answerCallbackQuery",
|
||||
text
|
||||
? { callback_query_id: callbackQueryId, text }
|
||||
: { callback_query_id: callbackQueryId },
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function createTelegramApiClient(
|
||||
getBotToken: () => string | undefined,
|
||||
): TelegramApiClient {
|
||||
return {
|
||||
call: async (method, body, options) => {
|
||||
return callTelegram(getBotToken(), method, body, options);
|
||||
},
|
||||
callMultipart: async (
|
||||
method,
|
||||
fields,
|
||||
fileField,
|
||||
filePath,
|
||||
fileName,
|
||||
options,
|
||||
) => {
|
||||
return callTelegramMultipart(
|
||||
getBotToken(),
|
||||
method,
|
||||
fields,
|
||||
fileField,
|
||||
filePath,
|
||||
fileName,
|
||||
options,
|
||||
);
|
||||
},
|
||||
downloadFile: async (fileId, suggestedName, tempDir) => {
|
||||
return downloadTelegramFile(
|
||||
getBotToken(),
|
||||
fileId,
|
||||
suggestedName,
|
||||
tempDir,
|
||||
);
|
||||
},
|
||||
answerCallbackQuery: async (callbackQueryId, text) => {
|
||||
await answerTelegramCallbackQuery(getBotToken(), callbackQueryId, text);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Telegram attachment domain helpers
|
||||
* Owns attachment queueing and attachment delivery so Telegram file output stays in one domain module
|
||||
*/
|
||||
|
||||
import { basename } from "node:path";
|
||||
|
||||
import { guessMediaType } from "./media.ts";
|
||||
import type { PendingTelegramTurn } from "./queue.ts";
|
||||
|
||||
export interface TelegramAttachmentToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: { paths: string[] };
|
||||
}
|
||||
|
||||
export interface TelegramQueuedAttachmentDeliveryDeps {
|
||||
sendMultipart: (
|
||||
method: string,
|
||||
fields: Record<string, string>,
|
||||
fileField: string,
|
||||
filePath: string,
|
||||
fileName: string,
|
||||
) => Promise<unknown>;
|
||||
sendTextReply: (
|
||||
chatId: number,
|
||||
replyToMessageId: number,
|
||||
text: string,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function queueTelegramAttachments(options: {
|
||||
activeTurn: PendingTelegramTurn | undefined;
|
||||
paths: string[];
|
||||
maxAttachmentsPerTurn: number;
|
||||
statPath: (path: string) => Promise<{ isFile(): boolean }>;
|
||||
}): Promise<TelegramAttachmentToolResult> {
|
||||
if (!options.activeTurn) {
|
||||
throw new Error(
|
||||
"telegram_attach can only be used while replying to an active Telegram turn",
|
||||
);
|
||||
}
|
||||
const added: string[] = [];
|
||||
for (const inputPath of options.paths) {
|
||||
const stats = await options.statPath(inputPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Not a file: ${inputPath}`);
|
||||
}
|
||||
if (
|
||||
options.activeTurn.queuedAttachments.length >=
|
||||
options.maxAttachmentsPerTurn
|
||||
) {
|
||||
throw new Error(
|
||||
`Attachment limit reached (${options.maxAttachmentsPerTurn})`,
|
||||
);
|
||||
}
|
||||
options.activeTurn.queuedAttachments.push({
|
||||
path: inputPath,
|
||||
fileName: basename(inputPath),
|
||||
});
|
||||
added.push(inputPath);
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Queued ${added.length} Telegram attachment(s).`,
|
||||
},
|
||||
],
|
||||
details: { paths: added },
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendQueuedTelegramAttachments(
|
||||
turn: PendingTelegramTurn,
|
||||
deps: TelegramQueuedAttachmentDeliveryDeps,
|
||||
): Promise<void> {
|
||||
for (const attachment of turn.queuedAttachments) {
|
||||
try {
|
||||
const mediaType = guessMediaType(attachment.path);
|
||||
const method = mediaType ? "sendPhoto" : "sendDocument";
|
||||
const fieldName = mediaType ? "photo" : "document";
|
||||
await deps.sendMultipart(
|
||||
method,
|
||||
{ chat_id: String(turn.chatId) },
|
||||
fieldName,
|
||||
attachment.path,
|
||||
attachment.fileName,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await deps.sendTextReply(
|
||||
turn.chatId,
|
||||
turn.replyToMessageId,
|
||||
`Failed to send attachment ${attachment.fileName}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Telegram media and text extraction helpers
|
||||
* Normalizes inbound Telegram messages into reusable file, text, id, and history metadata
|
||||
*/
|
||||
|
||||
export interface TelegramPhotoSizeLike {
|
||||
file_id: string;
|
||||
file_size?: number;
|
||||
}
|
||||
|
||||
export interface TelegramDocumentLike {
|
||||
file_id: string;
|
||||
file_name?: string;
|
||||
mime_type?: string;
|
||||
}
|
||||
|
||||
export interface TelegramVideoLike {
|
||||
file_id: string;
|
||||
file_name?: string;
|
||||
mime_type?: string;
|
||||
}
|
||||
|
||||
export interface TelegramAudioLike {
|
||||
file_id: string;
|
||||
file_name?: string;
|
||||
mime_type?: string;
|
||||
}
|
||||
|
||||
export interface TelegramVoiceLike {
|
||||
file_id: string;
|
||||
mime_type?: string;
|
||||
}
|
||||
|
||||
export interface TelegramAnimationLike {
|
||||
file_id: string;
|
||||
file_name?: string;
|
||||
mime_type?: string;
|
||||
}
|
||||
|
||||
export interface TelegramStickerLike {
|
||||
file_id: string;
|
||||
}
|
||||
|
||||
export interface TelegramMessageLike {
|
||||
message_id: number;
|
||||
text?: string;
|
||||
caption?: string;
|
||||
photo?: TelegramPhotoSizeLike[];
|
||||
document?: TelegramDocumentLike;
|
||||
video?: TelegramVideoLike;
|
||||
audio?: TelegramAudioLike;
|
||||
voice?: TelegramVoiceLike;
|
||||
animation?: TelegramAnimationLike;
|
||||
sticker?: TelegramStickerLike;
|
||||
}
|
||||
|
||||
export interface TelegramFileInfo {
|
||||
file_id: string;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
isImage: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadedTelegramFileLike {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function guessExtensionFromMime(
|
||||
mimeType: string | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (!mimeType) return fallback;
|
||||
const normalized = mimeType.toLowerCase();
|
||||
if (normalized === "image/jpeg") return ".jpg";
|
||||
if (normalized === "image/png") return ".png";
|
||||
if (normalized === "image/webp") return ".webp";
|
||||
if (normalized === "image/gif") return ".gif";
|
||||
if (normalized === "audio/ogg") return ".ogg";
|
||||
if (normalized === "audio/mpeg") return ".mp3";
|
||||
if (normalized === "audio/wav") return ".wav";
|
||||
if (normalized === "video/mp4") return ".mp4";
|
||||
if (normalized === "application/pdf") return ".pdf";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function guessMediaType(path: string): string | undefined {
|
||||
const normalized = path.toLowerCase();
|
||||
if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (normalized.endsWith(".png")) return "image/png";
|
||||
if (normalized.endsWith(".webp")) return "image/webp";
|
||||
if (normalized.endsWith(".gif")) return "image/gif";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isImageMimeType(mimeType: string | undefined): boolean {
|
||||
return mimeType?.toLowerCase().startsWith("image/") ?? false;
|
||||
}
|
||||
|
||||
export function extractTelegramMessageText(
|
||||
message: TelegramMessageLike,
|
||||
): string {
|
||||
return (message.text || message.caption || "").trim();
|
||||
}
|
||||
|
||||
export function extractTelegramMessagesText(
|
||||
messages: TelegramMessageLike[],
|
||||
): string {
|
||||
return messages.map(extractTelegramMessageText).filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
export function extractFirstTelegramMessageText(
|
||||
messages: TelegramMessageLike[],
|
||||
): string {
|
||||
return messages.map(extractTelegramMessageText).find(Boolean) ?? "";
|
||||
}
|
||||
|
||||
export function collectTelegramMessageIds(
|
||||
messages: TelegramMessageLike[],
|
||||
): number[] {
|
||||
return [...new Set(messages.map((message) => message.message_id))];
|
||||
}
|
||||
|
||||
export function formatTelegramHistoryText(
|
||||
rawText: string,
|
||||
files: DownloadedTelegramFileLike[],
|
||||
): string {
|
||||
let summary = rawText.length > 0 ? rawText : "(no text)";
|
||||
if (files.length > 0) {
|
||||
summary += `\nAttachments:`;
|
||||
for (const file of files) {
|
||||
summary += `\n- ${file.path}`;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function collectTelegramFileInfos(
|
||||
messages: TelegramMessageLike[],
|
||||
): TelegramFileInfo[] {
|
||||
const files: TelegramFileInfo[] = [];
|
||||
for (const message of messages) {
|
||||
if (Array.isArray(message.photo) && message.photo.length > 0) {
|
||||
const photo = [...message.photo]
|
||||
.sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0))
|
||||
.pop();
|
||||
if (photo) {
|
||||
files.push({
|
||||
file_id: photo.file_id,
|
||||
fileName: `photo-${message.message_id}.jpg`,
|
||||
mimeType: "image/jpeg",
|
||||
isImage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (message.document) {
|
||||
const fileName =
|
||||
message.document.file_name ||
|
||||
`document-${message.message_id}${guessExtensionFromMime(
|
||||
message.document.mime_type,
|
||||
"",
|
||||
)}`;
|
||||
files.push({
|
||||
file_id: message.document.file_id,
|
||||
fileName,
|
||||
mimeType: message.document.mime_type,
|
||||
isImage: isImageMimeType(message.document.mime_type),
|
||||
});
|
||||
}
|
||||
if (message.video) {
|
||||
const fileName =
|
||||
message.video.file_name ||
|
||||
`video-${message.message_id}${guessExtensionFromMime(
|
||||
message.video.mime_type,
|
||||
".mp4",
|
||||
)}`;
|
||||
files.push({
|
||||
file_id: message.video.file_id,
|
||||
fileName,
|
||||
mimeType: message.video.mime_type,
|
||||
isImage: false,
|
||||
});
|
||||
}
|
||||
if (message.audio) {
|
||||
const fileName =
|
||||
message.audio.file_name ||
|
||||
`audio-${message.message_id}${guessExtensionFromMime(
|
||||
message.audio.mime_type,
|
||||
".mp3",
|
||||
)}`;
|
||||
files.push({
|
||||
file_id: message.audio.file_id,
|
||||
fileName,
|
||||
mimeType: message.audio.mime_type,
|
||||
isImage: false,
|
||||
});
|
||||
}
|
||||
if (message.voice) {
|
||||
files.push({
|
||||
file_id: message.voice.file_id,
|
||||
fileName: `voice-${message.message_id}${guessExtensionFromMime(
|
||||
message.voice.mime_type,
|
||||
".ogg",
|
||||
)}`,
|
||||
mimeType: message.voice.mime_type,
|
||||
isImage: false,
|
||||
});
|
||||
}
|
||||
if (message.animation) {
|
||||
const fileName =
|
||||
message.animation.file_name ||
|
||||
`animation-${message.message_id}${guessExtensionFromMime(
|
||||
message.animation.mime_type,
|
||||
".mp4",
|
||||
)}`;
|
||||
files.push({
|
||||
file_id: message.animation.file_id,
|
||||
fileName,
|
||||
mimeType: message.animation.mime_type,
|
||||
isImage: false,
|
||||
});
|
||||
}
|
||||
if (message.sticker) {
|
||||
files.push({
|
||||
file_id: message.sticker.file_id,
|
||||
fileName: `sticker-${message.message_id}.webp`,
|
||||
mimeType: "image/webp",
|
||||
isImage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
+951
@@ -0,0 +1,951 @@
|
||||
/**
|
||||
* Telegram menu and inline-keyboard rendering helpers
|
||||
* Owns model resolution, menu state, and inline UI text and reply-markup generation for status, model, and thinking controls
|
||||
*/
|
||||
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
|
||||
export type ThinkingLevel =
|
||||
| "off"
|
||||
| "minimal"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
| "xhigh";
|
||||
export type TelegramModelScope = "all" | "scoped";
|
||||
|
||||
export interface ScopedTelegramModel {
|
||||
model: Model<any>;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
|
||||
export interface TelegramModelMenuState {
|
||||
chatId: number;
|
||||
messageId: number;
|
||||
page: number;
|
||||
scope: TelegramModelScope;
|
||||
scopedModels: ScopedTelegramModel[];
|
||||
allModels: ScopedTelegramModel[];
|
||||
note?: string;
|
||||
mode: "status" | "model" | "thinking";
|
||||
}
|
||||
|
||||
export type TelegramReplyMarkup = {
|
||||
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
|
||||
};
|
||||
|
||||
export interface TelegramMenuMessageRuntimeDeps {
|
||||
editInteractiveMessage: (
|
||||
chatId: number,
|
||||
messageId: number,
|
||||
text: string,
|
||||
mode: "html" | "plain",
|
||||
replyMarkup: TelegramReplyMarkup,
|
||||
) => Promise<void>;
|
||||
sendInteractiveMessage: (
|
||||
chatId: number,
|
||||
text: string,
|
||||
mode: "html" | "plain",
|
||||
replyMarkup: TelegramReplyMarkup,
|
||||
) => Promise<number | undefined>;
|
||||
}
|
||||
|
||||
export interface TelegramMenuEffectPort {
|
||||
answerCallbackQuery: (
|
||||
callbackQueryId: string,
|
||||
text?: string,
|
||||
) => Promise<void>;
|
||||
updateModelMenuMessage: () => Promise<void>;
|
||||
updateThinkingMenuMessage: () => Promise<void>;
|
||||
updateStatusMessage: () => Promise<void>;
|
||||
setModel: (model: Model<any>) => Promise<boolean>;
|
||||
setCurrentModel: (model: Model<any>) => void;
|
||||
setThinkingLevel: (level: ThinkingLevel) => void;
|
||||
getCurrentThinkingLevel: () => ThinkingLevel;
|
||||
stagePendingModelSwitch: (selection: ScopedTelegramModel) => void;
|
||||
restartInterruptedTelegramTurn: (
|
||||
selection: ScopedTelegramModel,
|
||||
) => Promise<boolean> | boolean;
|
||||
}
|
||||
|
||||
export type TelegramStatusMenuCallbackDeps = Pick<
|
||||
TelegramMenuEffectPort,
|
||||
"updateModelMenuMessage" | "updateThinkingMenuMessage" | "answerCallbackQuery"
|
||||
>;
|
||||
|
||||
export type TelegramThinkingMenuCallbackDeps = Pick<
|
||||
TelegramMenuEffectPort,
|
||||
"setThinkingLevel" | "getCurrentThinkingLevel" | "updateStatusMessage" | "answerCallbackQuery"
|
||||
>;
|
||||
|
||||
export type TelegramModelMenuCallbackDeps = Pick<
|
||||
TelegramMenuEffectPort,
|
||||
| "updateModelMenuMessage"
|
||||
| "updateStatusMessage"
|
||||
| "answerCallbackQuery"
|
||||
| "setModel"
|
||||
| "setCurrentModel"
|
||||
| "setThinkingLevel"
|
||||
| "stagePendingModelSwitch"
|
||||
| "restartInterruptedTelegramTurn"
|
||||
>;
|
||||
|
||||
export interface TelegramMenuCallbackEntryDeps {
|
||||
handleStatusAction: () => Promise<boolean>;
|
||||
handleThinkingAction: () => Promise<boolean>;
|
||||
handleModelAction: () => Promise<boolean>;
|
||||
answerCallbackQuery: (
|
||||
callbackQueryId: string,
|
||||
text?: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export const THINKING_LEVELS: readonly ThinkingLevel[] = [
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
];
|
||||
export const TELEGRAM_MODEL_PAGE_SIZE = 6;
|
||||
export const MODEL_MENU_TITLE = "<b>Choose a model:</b>";
|
||||
|
||||
export interface BuildTelegramModelMenuStateParams {
|
||||
chatId: number;
|
||||
activeModel: Model<any> | undefined;
|
||||
availableModels: Model<any>[];
|
||||
configuredScopedModelPatterns: string[];
|
||||
cliScopedModelPatterns?: string[];
|
||||
}
|
||||
|
||||
export type TelegramMenuCallbackAction =
|
||||
| { kind: "ignore" }
|
||||
| { kind: "status"; action: "model" | "thinking" }
|
||||
| { kind: "thinking:set"; level: string }
|
||||
| {
|
||||
kind: "model";
|
||||
action: "noop" | "scope" | "page" | "pick";
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type TelegramMenuMutationResult = "invalid" | "unchanged" | "changed";
|
||||
export type TelegramMenuSelectionResult =
|
||||
| { kind: "invalid" }
|
||||
| { kind: "missing" }
|
||||
| { kind: "selected"; selection: ScopedTelegramModel };
|
||||
|
||||
export interface TelegramModelMenuPage {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
start: number;
|
||||
items: ScopedTelegramModel[];
|
||||
}
|
||||
|
||||
export interface TelegramMenuRenderPayload {
|
||||
nextMode: TelegramModelMenuState["mode"];
|
||||
text: string;
|
||||
mode: "html" | "plain";
|
||||
replyMarkup: TelegramReplyMarkup;
|
||||
}
|
||||
|
||||
export type TelegramModelCallbackPlan =
|
||||
| { kind: "ignore" }
|
||||
| { kind: "answer"; text?: string }
|
||||
| { kind: "update-menu"; text?: string }
|
||||
| {
|
||||
kind: "refresh-status";
|
||||
selection: ScopedTelegramModel;
|
||||
callbackText: string;
|
||||
shouldApplyThinkingLevel: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "switch-model";
|
||||
selection: ScopedTelegramModel;
|
||||
mode: "idle" | "restart-now" | "restart-after-tool";
|
||||
callbackText: string;
|
||||
};
|
||||
|
||||
export interface BuildTelegramModelCallbackPlanParams {
|
||||
data: string | undefined;
|
||||
state: TelegramModelMenuState;
|
||||
activeModel: Model<any> | undefined;
|
||||
currentThinkingLevel: ThinkingLevel;
|
||||
isIdle: boolean;
|
||||
canRestartBusyRun: boolean;
|
||||
hasActiveToolExecutions: boolean;
|
||||
}
|
||||
|
||||
export function modelsMatch(
|
||||
a: Pick<Model<any>, "provider" | "id"> | undefined,
|
||||
b: Pick<Model<any>, "provider" | "id"> | undefined,
|
||||
): boolean {
|
||||
return !!a && !!b && a.provider === b.provider && a.id === b.id;
|
||||
}
|
||||
|
||||
export function getCanonicalModelId(
|
||||
model: Pick<Model<any>, "provider" | "id">,
|
||||
): string {
|
||||
return `${model.provider}/${model.id}`;
|
||||
}
|
||||
|
||||
export function isThinkingLevel(value: string): value is ThinkingLevel {
|
||||
return THINKING_LEVELS.includes(value as ThinkingLevel);
|
||||
}
|
||||
|
||||
function escapeRegex(text: string): string {
|
||||
return text.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);
|
||||
regex += content.startsWith("!")
|
||||
? `[^${content.slice(1)}]`
|
||||
: `[${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<any>[],
|
||||
): Model<any> | 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<any>[],
|
||||
): Model<any> | 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<any>[],
|
||||
): { model: Model<any> | 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);
|
||||
}
|
||||
|
||||
export function resolveScopedModelPatterns(
|
||||
patterns: string[],
|
||||
availableModels: Model<any>[],
|
||||
): ScopedTelegramModel[] {
|
||||
const resolved: ScopedTelegramModel[] = [];
|
||||
const seen = new Set<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
export function sortScopedModels(
|
||||
models: ScopedTelegramModel[],
|
||||
currentModel: Model<any> | 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 truncateTelegramButtonLabel(label: string, maxLength = 56): string {
|
||||
return label.length <= maxLength
|
||||
? label
|
||||
: `${label.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
export function formatScopedModelButtonText(
|
||||
entry: ScopedTelegramModel,
|
||||
currentModel: Model<any> | undefined,
|
||||
): string {
|
||||
let label = `${modelsMatch(entry.model, currentModel) ? "✅ " : ""}${entry.model.id} [${entry.model.provider}]`;
|
||||
if (entry.thinkingLevel) {
|
||||
label += ` · ${entry.thinkingLevel}`;
|
||||
}
|
||||
return truncateTelegramButtonLabel(label);
|
||||
}
|
||||
|
||||
export function formatStatusButtonLabel(label: string, value: string): string {
|
||||
return truncateTelegramButtonLabel(`${label}: ${value}`, 64);
|
||||
}
|
||||
|
||||
export function getModelMenuItems(
|
||||
state: TelegramModelMenuState,
|
||||
): ScopedTelegramModel[] {
|
||||
return state.scope === "scoped" && state.scopedModels.length > 0
|
||||
? state.scopedModels
|
||||
: state.allModels;
|
||||
}
|
||||
|
||||
export function buildTelegramModelMenuState(
|
||||
params: BuildTelegramModelMenuStateParams,
|
||||
): TelegramModelMenuState {
|
||||
const allModels = sortScopedModels(
|
||||
params.availableModels.map((model) => ({ model })),
|
||||
params.activeModel,
|
||||
);
|
||||
const scopedModels =
|
||||
params.configuredScopedModelPatterns.length > 0
|
||||
? sortScopedModels(
|
||||
resolveScopedModelPatterns(
|
||||
params.configuredScopedModelPatterns,
|
||||
params.availableModels,
|
||||
),
|
||||
params.activeModel,
|
||||
)
|
||||
: [];
|
||||
let note: string | undefined;
|
||||
if (
|
||||
params.configuredScopedModelPatterns.length > 0 &&
|
||||
scopedModels.length === 0
|
||||
) {
|
||||
note = params.cliScopedModelPatterns
|
||||
? "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: params.chatId,
|
||||
messageId: 0,
|
||||
page: 0,
|
||||
scope: scopedModels.length > 0 ? "scoped" : "all",
|
||||
scopedModels,
|
||||
allModels,
|
||||
note,
|
||||
mode: "status",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTelegramMenuCallbackAction(
|
||||
data: string | undefined,
|
||||
): TelegramMenuCallbackAction {
|
||||
if (data === "status:model") return { kind: "status", action: "model" };
|
||||
if (data === "status:thinking") {
|
||||
return { kind: "status", action: "thinking" };
|
||||
}
|
||||
if (data?.startsWith("thinking:set:")) {
|
||||
return {
|
||||
kind: "thinking:set",
|
||||
level: data.slice("thinking:set:".length),
|
||||
};
|
||||
}
|
||||
if (data?.startsWith("model:")) {
|
||||
const [, action, value] = data.split(":");
|
||||
if (
|
||||
action === "noop" ||
|
||||
action === "scope" ||
|
||||
action === "page" ||
|
||||
action === "pick"
|
||||
) {
|
||||
return { kind: "model", action, value };
|
||||
}
|
||||
}
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
|
||||
export function applyTelegramModelScopeSelection(
|
||||
state: TelegramModelMenuState,
|
||||
value: string | undefined,
|
||||
): TelegramMenuMutationResult {
|
||||
if (value !== "all" && value !== "scoped") return "invalid";
|
||||
if (value === state.scope) return "unchanged";
|
||||
state.scope = value;
|
||||
state.page = 0;
|
||||
return "changed";
|
||||
}
|
||||
|
||||
export function applyTelegramModelPageSelection(
|
||||
state: TelegramModelMenuState,
|
||||
value: string | undefined,
|
||||
): TelegramMenuMutationResult {
|
||||
const page = Number(value);
|
||||
if (!Number.isFinite(page)) return "invalid";
|
||||
if (page === state.page) return "unchanged";
|
||||
state.page = page;
|
||||
return "changed";
|
||||
}
|
||||
|
||||
export function getTelegramModelSelection(
|
||||
state: TelegramModelMenuState,
|
||||
value: string | undefined,
|
||||
): TelegramMenuSelectionResult {
|
||||
const index = Number(value);
|
||||
if (!Number.isFinite(index)) return { kind: "invalid" };
|
||||
const selection = getModelMenuItems(state)[index];
|
||||
if (!selection) return { kind: "missing" };
|
||||
return { kind: "selected", selection };
|
||||
}
|
||||
|
||||
export function buildTelegramModelCallbackPlan(
|
||||
params: BuildTelegramModelCallbackPlanParams,
|
||||
): TelegramModelCallbackPlan {
|
||||
const action = parseTelegramMenuCallbackAction(params.data);
|
||||
if (action.kind !== "model") return { kind: "ignore" };
|
||||
if (action.action === "noop") return { kind: "answer" };
|
||||
if (action.action === "scope") {
|
||||
const result = applyTelegramModelScopeSelection(params.state, action.value);
|
||||
if (result === "invalid") {
|
||||
return { kind: "answer", text: "Unknown model scope." };
|
||||
}
|
||||
if (result === "unchanged") {
|
||||
return { kind: "answer" };
|
||||
}
|
||||
return {
|
||||
kind: "update-menu",
|
||||
text: params.state.scope === "scoped" ? "Scoped models" : "All models",
|
||||
};
|
||||
}
|
||||
if (action.action === "page") {
|
||||
const result = applyTelegramModelPageSelection(params.state, action.value);
|
||||
if (result === "invalid") {
|
||||
return { kind: "answer", text: "Invalid page." };
|
||||
}
|
||||
if (result === "unchanged") {
|
||||
return { kind: "answer" };
|
||||
}
|
||||
return { kind: "update-menu" };
|
||||
}
|
||||
if (action.action !== "pick") {
|
||||
return { kind: "answer" };
|
||||
}
|
||||
const selectionResult = getTelegramModelSelection(params.state, action.value);
|
||||
if (selectionResult.kind === "invalid") {
|
||||
return { kind: "answer", text: "Invalid model selection." };
|
||||
}
|
||||
if (selectionResult.kind === "missing") {
|
||||
return { kind: "answer", text: "Selected model is no longer available." };
|
||||
}
|
||||
const selection = selectionResult.selection;
|
||||
if (modelsMatch(selection.model, params.activeModel)) {
|
||||
return {
|
||||
kind: "refresh-status",
|
||||
selection,
|
||||
callbackText: `Model: ${selection.model.id}`,
|
||||
shouldApplyThinkingLevel:
|
||||
!!selection.thinkingLevel &&
|
||||
selection.thinkingLevel !== params.currentThinkingLevel,
|
||||
};
|
||||
}
|
||||
if (!params.isIdle) {
|
||||
if (!params.canRestartBusyRun) {
|
||||
return { kind: "answer", text: "Pi is busy. Send /stop first." };
|
||||
}
|
||||
return {
|
||||
kind: "switch-model",
|
||||
selection,
|
||||
mode: params.hasActiveToolExecutions
|
||||
? "restart-after-tool"
|
||||
: "restart-now",
|
||||
callbackText: params.hasActiveToolExecutions
|
||||
? `Switched to ${selection.model.id}. Restarting after the current tool finishes…`
|
||||
: `Switching to ${selection.model.id} and continuing…`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "switch-model",
|
||||
selection,
|
||||
mode: "idle",
|
||||
callbackText: `Switched to ${selection.model.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleTelegramMenuCallbackEntry(
|
||||
callbackQueryId: string,
|
||||
data: string | undefined,
|
||||
state: TelegramModelMenuState | undefined,
|
||||
deps: TelegramMenuCallbackEntryDeps,
|
||||
): Promise<void> {
|
||||
if (!data) {
|
||||
await deps.answerCallbackQuery(callbackQueryId);
|
||||
return;
|
||||
}
|
||||
if (!state) {
|
||||
await deps.answerCallbackQuery(callbackQueryId, "Interactive message expired.");
|
||||
return;
|
||||
}
|
||||
const handled =
|
||||
(await deps.handleStatusAction()) ||
|
||||
(await deps.handleThinkingAction()) ||
|
||||
(await deps.handleModelAction());
|
||||
if (!handled) {
|
||||
await deps.answerCallbackQuery(callbackQueryId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleTelegramModelMenuCallbackAction(
|
||||
callbackQueryId: string,
|
||||
params: BuildTelegramModelCallbackPlanParams,
|
||||
deps: TelegramModelMenuCallbackDeps,
|
||||
): Promise<boolean> {
|
||||
const plan = buildTelegramModelCallbackPlan(params);
|
||||
if (plan.kind === "ignore") return false;
|
||||
if (plan.kind === "answer") {
|
||||
await deps.answerCallbackQuery(callbackQueryId, plan.text);
|
||||
return true;
|
||||
}
|
||||
if (plan.kind === "update-menu") {
|
||||
await deps.updateModelMenuMessage();
|
||||
await deps.answerCallbackQuery(callbackQueryId, plan.text);
|
||||
return true;
|
||||
}
|
||||
if (plan.kind === "refresh-status") {
|
||||
if (plan.shouldApplyThinkingLevel && plan.selection.thinkingLevel) {
|
||||
deps.setThinkingLevel(plan.selection.thinkingLevel);
|
||||
}
|
||||
await deps.updateStatusMessage();
|
||||
await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
|
||||
return true;
|
||||
}
|
||||
const changed = await deps.setModel(plan.selection.model);
|
||||
if (changed === false) {
|
||||
await deps.answerCallbackQuery(callbackQueryId, "Model is not available.");
|
||||
return true;
|
||||
}
|
||||
deps.setCurrentModel(plan.selection.model);
|
||||
if (plan.selection.thinkingLevel) {
|
||||
deps.setThinkingLevel(plan.selection.thinkingLevel);
|
||||
}
|
||||
await deps.updateStatusMessage();
|
||||
if (plan.mode === "restart-after-tool") {
|
||||
deps.stagePendingModelSwitch(plan.selection);
|
||||
await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
|
||||
return true;
|
||||
}
|
||||
if (plan.mode === "restart-now") {
|
||||
const restarted = await deps.restartInterruptedTelegramTurn(plan.selection);
|
||||
if (!restarted) {
|
||||
await deps.answerCallbackQuery(
|
||||
callbackQueryId,
|
||||
"Pi is busy. Send /stop first.",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await deps.answerCallbackQuery(callbackQueryId, plan.callbackText);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function handleTelegramStatusMenuCallbackAction(
|
||||
callbackQueryId: string,
|
||||
data: string | undefined,
|
||||
activeModel: Model<any> | undefined,
|
||||
deps: TelegramStatusMenuCallbackDeps,
|
||||
): Promise<boolean> {
|
||||
const action = parseTelegramMenuCallbackAction(data);
|
||||
if (action.kind === "status" && action.action === "model") {
|
||||
await deps.updateModelMenuMessage();
|
||||
await deps.answerCallbackQuery(callbackQueryId);
|
||||
return true;
|
||||
}
|
||||
if (!(action.kind === "status" && action.action === "thinking")) {
|
||||
return false;
|
||||
}
|
||||
if (!activeModel?.reasoning) {
|
||||
await deps.answerCallbackQuery(
|
||||
callbackQueryId,
|
||||
"This model has no reasoning controls.",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
await deps.updateThinkingMenuMessage();
|
||||
await deps.answerCallbackQuery(callbackQueryId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function handleTelegramThinkingMenuCallbackAction(
|
||||
callbackQueryId: string,
|
||||
data: string | undefined,
|
||||
activeModel: Model<any> | undefined,
|
||||
deps: TelegramThinkingMenuCallbackDeps,
|
||||
): Promise<boolean> {
|
||||
const action = parseTelegramMenuCallbackAction(data);
|
||||
if (action.kind !== "thinking:set") return false;
|
||||
if (!isThinkingLevel(action.level)) {
|
||||
await deps.answerCallbackQuery(callbackQueryId, "Invalid thinking level.");
|
||||
return true;
|
||||
}
|
||||
if (!activeModel?.reasoning) {
|
||||
await deps.answerCallbackQuery(
|
||||
callbackQueryId,
|
||||
"This model has no reasoning controls.",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
deps.setThinkingLevel(action.level);
|
||||
await deps.updateStatusMessage();
|
||||
await deps.answerCallbackQuery(
|
||||
callbackQueryId,
|
||||
`Thinking: ${deps.getCurrentThinkingLevel()}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildThinkingMenuText(
|
||||
activeModel: Model<any> | undefined,
|
||||
currentThinkingLevel: ThinkingLevel,
|
||||
): string {
|
||||
const lines = ["Choose a thinking level"];
|
||||
if (activeModel) {
|
||||
lines.push(`Model: ${getCanonicalModelId(activeModel)}`);
|
||||
}
|
||||
lines.push(`Current: ${currentThinkingLevel}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function getTelegramModelMenuPage(
|
||||
state: TelegramModelMenuState,
|
||||
pageSize: number,
|
||||
): TelegramModelMenuPage {
|
||||
const items = getModelMenuItems(state);
|
||||
const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
|
||||
const page = Math.max(0, Math.min(state.page, pageCount - 1));
|
||||
const start = page * pageSize;
|
||||
return {
|
||||
page,
|
||||
pageCount,
|
||||
start,
|
||||
items: items.slice(start, start + pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildModelMenuReplyMarkup(
|
||||
state: TelegramModelMenuState,
|
||||
currentModel: Model<any> | undefined,
|
||||
pageSize: number,
|
||||
): TelegramReplyMarkup {
|
||||
const menuPage = getTelegramModelMenuPage(state, pageSize);
|
||||
const rows = menuPage.items.map((entry, index) => [
|
||||
{
|
||||
text: formatScopedModelButtonText(entry, currentModel),
|
||||
callback_data: `model:pick:${menuPage.start + index}`,
|
||||
},
|
||||
]);
|
||||
if (menuPage.pageCount > 1) {
|
||||
const previousPage =
|
||||
menuPage.page === 0 ? menuPage.pageCount - 1 : menuPage.page - 1;
|
||||
const nextPage =
|
||||
menuPage.page === menuPage.pageCount - 1 ? 0 : menuPage.page + 1;
|
||||
rows.push([
|
||||
{ text: "⬅️", callback_data: `model:page:${previousPage}` },
|
||||
{
|
||||
text: `${menuPage.page + 1}/${menuPage.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 };
|
||||
}
|
||||
|
||||
export function buildThinkingMenuReplyMarkup(
|
||||
currentThinkingLevel: ThinkingLevel,
|
||||
): TelegramReplyMarkup {
|
||||
return {
|
||||
inline_keyboard: THINKING_LEVELS.map((level) => [
|
||||
{
|
||||
text: level === currentThinkingLevel ? `✅ ${level}` : level,
|
||||
callback_data: `thinking:set:${level}`,
|
||||
},
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStatusReplyMarkup(
|
||||
activeModel: Model<any> | undefined,
|
||||
currentThinkingLevel: ThinkingLevel,
|
||||
): TelegramReplyMarkup {
|
||||
const rows: Array<Array<{ text: string; callback_data: string }>> = [];
|
||||
rows.push([
|
||||
{
|
||||
text: formatStatusButtonLabel(
|
||||
"Model",
|
||||
activeModel ? getCanonicalModelId(activeModel) : "unknown",
|
||||
),
|
||||
callback_data: "status:model",
|
||||
},
|
||||
]);
|
||||
if (activeModel?.reasoning) {
|
||||
rows.push([
|
||||
{
|
||||
text: formatStatusButtonLabel("Thinking", currentThinkingLevel),
|
||||
callback_data: "status:thinking",
|
||||
},
|
||||
]);
|
||||
}
|
||||
return { inline_keyboard: rows };
|
||||
}
|
||||
|
||||
export function buildTelegramModelMenuRenderPayload(
|
||||
state: TelegramModelMenuState,
|
||||
activeModel: Model<any> | undefined,
|
||||
): TelegramMenuRenderPayload {
|
||||
return {
|
||||
nextMode: "model",
|
||||
text: MODEL_MENU_TITLE,
|
||||
mode: "html",
|
||||
replyMarkup: buildModelMenuReplyMarkup(
|
||||
state,
|
||||
activeModel,
|
||||
TELEGRAM_MODEL_PAGE_SIZE,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTelegramThinkingMenuRenderPayload(
|
||||
activeModel: Model<any> | undefined,
|
||||
currentThinkingLevel: ThinkingLevel,
|
||||
): TelegramMenuRenderPayload {
|
||||
return {
|
||||
nextMode: "thinking",
|
||||
text: buildThinkingMenuText(activeModel, currentThinkingLevel),
|
||||
mode: "plain",
|
||||
replyMarkup: buildThinkingMenuReplyMarkup(currentThinkingLevel),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTelegramStatusMenuRenderPayload(
|
||||
statusText: string,
|
||||
activeModel: Model<any> | undefined,
|
||||
currentThinkingLevel: ThinkingLevel,
|
||||
): TelegramMenuRenderPayload {
|
||||
return {
|
||||
nextMode: "status",
|
||||
text: statusText,
|
||||
mode: "html",
|
||||
replyMarkup: buildStatusReplyMarkup(activeModel, currentThinkingLevel),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateTelegramModelMenuMessage(
|
||||
state: TelegramModelMenuState,
|
||||
activeModel: Model<any> | undefined,
|
||||
deps: TelegramMenuMessageRuntimeDeps,
|
||||
): Promise<void> {
|
||||
const payload = buildTelegramModelMenuRenderPayload(state, activeModel);
|
||||
state.mode = payload.nextMode;
|
||||
await deps.editInteractiveMessage(
|
||||
state.chatId,
|
||||
state.messageId,
|
||||
payload.text,
|
||||
payload.mode,
|
||||
payload.replyMarkup,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateTelegramThinkingMenuMessage(
|
||||
state: TelegramModelMenuState,
|
||||
activeModel: Model<any> | undefined,
|
||||
currentThinkingLevel: ThinkingLevel,
|
||||
deps: TelegramMenuMessageRuntimeDeps,
|
||||
): Promise<void> {
|
||||
const payload = buildTelegramThinkingMenuRenderPayload(
|
||||
activeModel,
|
||||
currentThinkingLevel,
|
||||
);
|
||||
state.mode = payload.nextMode;
|
||||
await deps.editInteractiveMessage(
|
||||
state.chatId,
|
||||
state.messageId,
|
||||
payload.text,
|
||||
payload.mode,
|
||||
payload.replyMarkup,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateTelegramStatusMessage(
|
||||
state: TelegramModelMenuState,
|
||||
statusText: string,
|
||||
activeModel: Model<any> | undefined,
|
||||
currentThinkingLevel: ThinkingLevel,
|
||||
deps: TelegramMenuMessageRuntimeDeps,
|
||||
): Promise<void> {
|
||||
const payload = buildTelegramStatusMenuRenderPayload(
|
||||
statusText,
|
||||
activeModel,
|
||||
currentThinkingLevel,
|
||||
);
|
||||
state.mode = payload.nextMode;
|
||||
await deps.editInteractiveMessage(
|
||||
state.chatId,
|
||||
state.messageId,
|
||||
payload.text,
|
||||
payload.mode,
|
||||
payload.replyMarkup,
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendTelegramStatusMessage(
|
||||
state: TelegramModelMenuState,
|
||||
statusText: string,
|
||||
activeModel: Model<any> | undefined,
|
||||
currentThinkingLevel: ThinkingLevel,
|
||||
deps: TelegramMenuMessageRuntimeDeps,
|
||||
): Promise<number | undefined> {
|
||||
const payload = buildTelegramStatusMenuRenderPayload(
|
||||
statusText,
|
||||
activeModel,
|
||||
currentThinkingLevel,
|
||||
);
|
||||
state.mode = payload.nextMode;
|
||||
return deps.sendInteractiveMessage(
|
||||
state.chatId,
|
||||
payload.text,
|
||||
payload.mode,
|
||||
payload.replyMarkup,
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendTelegramModelMenuMessage(
|
||||
state: TelegramModelMenuState,
|
||||
activeModel: Model<any> | undefined,
|
||||
deps: TelegramMenuMessageRuntimeDeps,
|
||||
): Promise<number | undefined> {
|
||||
const payload = buildTelegramModelMenuRenderPayload(state, activeModel);
|
||||
state.mode = payload.nextMode;
|
||||
return deps.sendInteractiveMessage(
|
||||
state.chatId,
|
||||
payload.text,
|
||||
payload.mode,
|
||||
payload.replyMarkup,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* In-flight Telegram model-switch helpers
|
||||
* Encodes the safe restart and continuation rules for switching models during active Telegram-owned runs
|
||||
*/
|
||||
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
|
||||
import type { TelegramInFlightModelSwitchState } from "./queue.ts";
|
||||
|
||||
export type TelegramThinkingLevel =
|
||||
| "off"
|
||||
| "minimal"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
| "xhigh";
|
||||
|
||||
export function canRestartTelegramTurnForModelSwitch(
|
||||
state: TelegramInFlightModelSwitchState,
|
||||
): boolean {
|
||||
return !state.isIdle && state.hasActiveTelegramTurn && state.hasAbortHandler;
|
||||
}
|
||||
|
||||
export function shouldTriggerPendingTelegramModelSwitchAbort(state: {
|
||||
hasPendingModelSwitch: boolean;
|
||||
hasActiveTelegramTurn: boolean;
|
||||
hasAbortHandler: boolean;
|
||||
activeToolExecutions: number;
|
||||
}): boolean {
|
||||
return (
|
||||
state.hasPendingModelSwitch &&
|
||||
state.hasActiveTelegramTurn &&
|
||||
state.hasAbortHandler &&
|
||||
state.activeToolExecutions === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function restartTelegramModelSwitchContinuation<TTurn, TSelection>(state: {
|
||||
activeTurn: TTurn | undefined;
|
||||
abort: (() => void) | undefined;
|
||||
selection: TSelection;
|
||||
queueContinuation: (turn: TTurn, selection: TSelection) => void;
|
||||
}): boolean {
|
||||
if (!state.activeTurn || !state.abort) return false;
|
||||
state.queueContinuation(state.activeTurn, state.selection);
|
||||
state.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildTelegramModelSwitchContinuationText<
|
||||
TModel extends Pick<Model<any>, "provider" | "id">,
|
||||
>(
|
||||
telegramPrefix: string,
|
||||
model: TModel,
|
||||
thinkingLevel?: TelegramThinkingLevel,
|
||||
): string {
|
||||
const modelLabel = `${model.provider}/${model.id}`;
|
||||
const thinkingSuffix = thinkingLevel
|
||||
? ` Keep the selected thinking level (${thinkingLevel}) if it still applies.`
|
||||
: "";
|
||||
return `${telegramPrefix} Continue the interrupted previous Telegram request using the newly selected model (${modelLabel}). Resume from the last unfinished step instead of restarting from scratch unless necessary.${thinkingSuffix}`;
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Telegram polling domain helpers
|
||||
* Owns polling request builders, stop conditions, and the long-poll loop runtime for Telegram updates
|
||||
*/
|
||||
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import type { TelegramConfig } from "./api.ts";
|
||||
|
||||
export interface TelegramUpdateLike {
|
||||
update_id: number;
|
||||
}
|
||||
|
||||
export const TELEGRAM_ALLOWED_UPDATES = [
|
||||
"message",
|
||||
"edited_message",
|
||||
"callback_query",
|
||||
"message_reaction",
|
||||
] as const;
|
||||
|
||||
export function buildTelegramInitialSyncRequest(): {
|
||||
offset: number;
|
||||
limit: number;
|
||||
timeout: number;
|
||||
} {
|
||||
return {
|
||||
offset: -1,
|
||||
limit: 1,
|
||||
timeout: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTelegramLongPollRequest(lastUpdateId?: number): {
|
||||
offset?: number;
|
||||
limit: number;
|
||||
timeout: number;
|
||||
allowed_updates: readonly string[];
|
||||
} {
|
||||
return {
|
||||
offset: lastUpdateId !== undefined ? lastUpdateId + 1 : undefined,
|
||||
limit: 10,
|
||||
timeout: 30,
|
||||
allowed_updates: TELEGRAM_ALLOWED_UPDATES,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLatestTelegramUpdateId(
|
||||
updates: TelegramUpdateLike[],
|
||||
): number | undefined {
|
||||
return updates.at(-1)?.update_id;
|
||||
}
|
||||
|
||||
export function shouldStopTelegramPolling(
|
||||
signalAborted: boolean,
|
||||
error: unknown,
|
||||
): boolean {
|
||||
return (
|
||||
signalAborted ||
|
||||
(error instanceof DOMException && error.name === "AbortError")
|
||||
);
|
||||
}
|
||||
|
||||
export interface TelegramPollLoopDeps<TUpdate extends TelegramUpdateLike> {
|
||||
ctx: ExtensionContext;
|
||||
signal: AbortSignal;
|
||||
config: TelegramConfig;
|
||||
deleteWebhook: (signal: AbortSignal) => Promise<void>;
|
||||
getUpdates: (
|
||||
body: Record<string, unknown>,
|
||||
signal: AbortSignal,
|
||||
) => Promise<TUpdate[]>;
|
||||
persistConfig: () => Promise<void>;
|
||||
handleUpdate: (update: TUpdate, ctx: ExtensionContext) => Promise<void>;
|
||||
onErrorStatus: (message: string) => void;
|
||||
onStatusReset: () => void;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function runTelegramPollLoop<TUpdate extends TelegramUpdateLike>(
|
||||
deps: TelegramPollLoopDeps<TUpdate>,
|
||||
): Promise<void> {
|
||||
if (!deps.config.botToken) return;
|
||||
try {
|
||||
await deps.deleteWebhook(deps.signal);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (deps.config.lastUpdateId === undefined) {
|
||||
try {
|
||||
const updates = await deps.getUpdates(
|
||||
buildTelegramInitialSyncRequest(),
|
||||
deps.signal,
|
||||
);
|
||||
const lastUpdateId = getLatestTelegramUpdateId(updates);
|
||||
if (lastUpdateId !== undefined) {
|
||||
deps.config.lastUpdateId = lastUpdateId;
|
||||
await deps.persistConfig();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
while (!deps.signal.aborted) {
|
||||
try {
|
||||
const updates = await deps.getUpdates(
|
||||
buildTelegramLongPollRequest(deps.config.lastUpdateId),
|
||||
deps.signal,
|
||||
);
|
||||
for (const update of updates) {
|
||||
deps.config.lastUpdateId = update.update_id;
|
||||
await deps.persistConfig();
|
||||
await deps.handleUpdate(update, deps.ctx);
|
||||
}
|
||||
} catch (error) {
|
||||
if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.onErrorStatus(message);
|
||||
await deps.sleep(3000);
|
||||
deps.onStatusReset();
|
||||
}
|
||||
}
|
||||
}
|
||||
+534
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* Telegram queue and queue-runtime domain helpers
|
||||
* Owns queue items, queue mutations, dispatch and lifecycle planning, session resets, and queue-adjacent runtime helpers
|
||||
*/
|
||||
|
||||
import type { ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// --- Queue Items ---
|
||||
|
||||
export interface QueuedAttachment {
|
||||
path: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export type TelegramQueueItemKind = "prompt" | "control";
|
||||
export type TelegramQueueLane = "control" | "priority" | "default";
|
||||
|
||||
export interface TelegramQueueItemBase {
|
||||
kind: TelegramQueueItemKind;
|
||||
chatId: number;
|
||||
replyToMessageId: number;
|
||||
queueOrder: number;
|
||||
queueLane: TelegramQueueLane;
|
||||
laneOrder: number;
|
||||
statusSummary: string;
|
||||
}
|
||||
|
||||
export interface PendingTelegramTurn extends TelegramQueueItemBase {
|
||||
kind: "prompt";
|
||||
sourceMessageIds: number[];
|
||||
queuedAttachments: QueuedAttachment[];
|
||||
content: Array<TextContent | ImageContent>;
|
||||
historyText: string;
|
||||
}
|
||||
|
||||
export interface PendingTelegramControlItem extends TelegramQueueItemBase {
|
||||
kind: "control";
|
||||
controlType: "status" | "model";
|
||||
execute: (ctx: ExtensionContext) => Promise<void>;
|
||||
}
|
||||
|
||||
export type TelegramQueueItem =
|
||||
| PendingTelegramTurn
|
||||
| PendingTelegramControlItem;
|
||||
|
||||
export interface TelegramDispatchGuardState {
|
||||
compactionInProgress: boolean;
|
||||
hasActiveTelegramTurn: boolean;
|
||||
hasPendingTelegramDispatch: boolean;
|
||||
isIdle: boolean;
|
||||
hasPendingMessages: boolean;
|
||||
}
|
||||
|
||||
export interface TelegramInFlightModelSwitchState {
|
||||
isIdle: boolean;
|
||||
hasActiveTelegramTurn: boolean;
|
||||
hasAbortHandler: boolean;
|
||||
}
|
||||
|
||||
function getTelegramQueueLaneRank(lane: TelegramQueueLane): number {
|
||||
switch (lane) {
|
||||
case "control":
|
||||
return 0;
|
||||
case "priority":
|
||||
return 1;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPendingTelegramTurn(
|
||||
item: TelegramQueueItem,
|
||||
): item is PendingTelegramTurn {
|
||||
return item.kind === "prompt";
|
||||
}
|
||||
|
||||
// --- Queue Mutations ---
|
||||
|
||||
export function partitionTelegramQueueItemsForHistory(
|
||||
items: TelegramQueueItem[],
|
||||
): {
|
||||
historyTurns: PendingTelegramTurn[];
|
||||
remainingItems: TelegramQueueItem[];
|
||||
} {
|
||||
const historyTurns: PendingTelegramTurn[] = [];
|
||||
const remainingItems: TelegramQueueItem[] = [];
|
||||
for (const item of items) {
|
||||
if (isPendingTelegramTurn(item)) {
|
||||
historyTurns.push(item);
|
||||
continue;
|
||||
}
|
||||
remainingItems.push(item);
|
||||
}
|
||||
return { historyTurns, remainingItems };
|
||||
}
|
||||
|
||||
export function compareTelegramQueueItems(
|
||||
left: TelegramQueueItem,
|
||||
right: TelegramQueueItem,
|
||||
): number {
|
||||
const laneRankDelta =
|
||||
getTelegramQueueLaneRank(left.queueLane) -
|
||||
getTelegramQueueLaneRank(right.queueLane);
|
||||
if (laneRankDelta !== 0) return laneRankDelta;
|
||||
if (left.laneOrder !== right.laneOrder) {
|
||||
return left.laneOrder - right.laneOrder;
|
||||
}
|
||||
return left.queueOrder - right.queueOrder;
|
||||
}
|
||||
|
||||
export function removeTelegramQueueItemsByMessageIds(
|
||||
items: TelegramQueueItem[],
|
||||
messageIds: number[],
|
||||
): { items: TelegramQueueItem[]; removedCount: number } {
|
||||
if (messageIds.length === 0 || items.length === 0) {
|
||||
return { items, removedCount: 0 };
|
||||
}
|
||||
const deletedMessageIds = new Set(messageIds);
|
||||
const nextItems = items.filter((item) => {
|
||||
if (!isPendingTelegramTurn(item)) return true;
|
||||
return !item.sourceMessageIds.some((messageId) =>
|
||||
deletedMessageIds.has(messageId),
|
||||
);
|
||||
});
|
||||
return {
|
||||
items: nextItems,
|
||||
removedCount: items.length - nextItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearTelegramQueuePromptPriority(
|
||||
items: TelegramQueueItem[],
|
||||
messageId: number,
|
||||
): { items: TelegramQueueItem[]; changed: boolean } {
|
||||
let changed = false;
|
||||
const nextItems = items.map((item) => {
|
||||
if (
|
||||
!isPendingTelegramTurn(item) ||
|
||||
!item.sourceMessageIds.includes(messageId) ||
|
||||
item.queueLane !== "priority"
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...item,
|
||||
queueLane: "default" as const,
|
||||
laneOrder: item.queueOrder,
|
||||
};
|
||||
});
|
||||
return { items: nextItems, changed };
|
||||
}
|
||||
|
||||
export function prioritizeTelegramQueuePrompt(
|
||||
items: TelegramQueueItem[],
|
||||
messageId: number,
|
||||
laneOrder: number,
|
||||
): { items: TelegramQueueItem[]; changed: boolean } {
|
||||
let changed = false;
|
||||
const nextItems = items.map((item) => {
|
||||
if (
|
||||
!isPendingTelegramTurn(item) ||
|
||||
!item.sourceMessageIds.includes(messageId)
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...item,
|
||||
queueLane: "priority" as const,
|
||||
laneOrder,
|
||||
};
|
||||
});
|
||||
return { items: nextItems, changed };
|
||||
}
|
||||
|
||||
export function consumeDispatchedTelegramPrompt(
|
||||
items: TelegramQueueItem[],
|
||||
hasPendingDispatch: boolean,
|
||||
): { activeTurn?: PendingTelegramTurn; remainingItems: TelegramQueueItem[] } {
|
||||
if (!hasPendingDispatch) {
|
||||
return { activeTurn: undefined, remainingItems: items };
|
||||
}
|
||||
const nextItem = items[0];
|
||||
if (!nextItem || !isPendingTelegramTurn(nextItem)) {
|
||||
return { activeTurn: undefined, remainingItems: items };
|
||||
}
|
||||
return { activeTurn: nextItem, remainingItems: items.slice(1) };
|
||||
}
|
||||
|
||||
export function formatQueuedTelegramItemsStatus(
|
||||
items: TelegramQueueItem[],
|
||||
): string {
|
||||
if (items.length === 0) return "";
|
||||
const previewCount = 4;
|
||||
const summaries = items
|
||||
.slice(0, previewCount)
|
||||
.map((item) => item.statusSummary)
|
||||
.filter(Boolean);
|
||||
if (summaries.length === 0) return ` +${items.length}`;
|
||||
const suffix = items.length > summaries.length ? ", …" : "";
|
||||
return ` +${items.length}: [${summaries.join(", ")}${suffix}]`;
|
||||
}
|
||||
|
||||
export function canDispatchTelegramTurnState(
|
||||
state: TelegramDispatchGuardState,
|
||||
): boolean {
|
||||
return (
|
||||
!state.compactionInProgress &&
|
||||
!state.hasActiveTelegramTurn &&
|
||||
!state.hasPendingTelegramDispatch &&
|
||||
state.isIdle &&
|
||||
!state.hasPendingMessages
|
||||
);
|
||||
}
|
||||
|
||||
export function canRestartTelegramTurnForModelSwitch(
|
||||
state: TelegramInFlightModelSwitchState,
|
||||
): boolean {
|
||||
return !state.isIdle && state.hasActiveTelegramTurn && state.hasAbortHandler;
|
||||
}
|
||||
|
||||
export function shouldTriggerPendingTelegramModelSwitchAbort(state: {
|
||||
hasPendingModelSwitch: boolean;
|
||||
hasActiveTelegramTurn: boolean;
|
||||
hasAbortHandler: boolean;
|
||||
activeToolExecutions: number;
|
||||
}): boolean {
|
||||
return (
|
||||
state.hasPendingModelSwitch &&
|
||||
state.hasActiveTelegramTurn &&
|
||||
state.hasAbortHandler &&
|
||||
state.activeToolExecutions === 0
|
||||
);
|
||||
}
|
||||
|
||||
// --- Dispatch Planning ---
|
||||
|
||||
export type TelegramQueueDispatchAction =
|
||||
| { kind: "none"; remainingItems: TelegramQueueItem[] }
|
||||
| {
|
||||
kind: "control";
|
||||
item: PendingTelegramControlItem;
|
||||
remainingItems: TelegramQueueItem[];
|
||||
}
|
||||
| {
|
||||
kind: "prompt";
|
||||
item: PendingTelegramTurn;
|
||||
remainingItems: TelegramQueueItem[];
|
||||
};
|
||||
|
||||
export function planNextTelegramQueueAction(
|
||||
items: TelegramQueueItem[],
|
||||
canDispatch: boolean,
|
||||
): TelegramQueueDispatchAction {
|
||||
if (!canDispatch || items.length === 0) {
|
||||
return { kind: "none", remainingItems: items };
|
||||
}
|
||||
const [firstItem, ...remainingItems] = items;
|
||||
if (!firstItem) {
|
||||
return { kind: "none", remainingItems: items };
|
||||
}
|
||||
if (isPendingTelegramTurn(firstItem)) {
|
||||
return { kind: "prompt", item: firstItem, remainingItems: items };
|
||||
}
|
||||
return { kind: "control", item: firstItem, remainingItems };
|
||||
}
|
||||
|
||||
export function shouldDispatchAfterTelegramAgentEnd(options: {
|
||||
hasTurn: boolean;
|
||||
stopReason?: string;
|
||||
preserveQueuedTurnsAsHistory: boolean;
|
||||
}): boolean {
|
||||
if (!options.hasTurn) return true;
|
||||
if (options.stopReason === "aborted") {
|
||||
return !options.preserveQueuedTurnsAsHistory;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Agent Runtime ---
|
||||
|
||||
export interface TelegramAgentStartPlan {
|
||||
activeTurn?: PendingTelegramTurn;
|
||||
remainingItems: TelegramQueueItem[];
|
||||
shouldResetPendingModelSwitch: boolean;
|
||||
shouldResetToolExecutions: boolean;
|
||||
shouldClearDispatchPending: boolean;
|
||||
}
|
||||
|
||||
export function buildTelegramAgentStartPlan(options: {
|
||||
queuedItems: TelegramQueueItem[];
|
||||
hasPendingDispatch: boolean;
|
||||
hasActiveTurn: boolean;
|
||||
}): TelegramAgentStartPlan {
|
||||
if (options.hasActiveTurn || !options.hasPendingDispatch) {
|
||||
return {
|
||||
activeTurn: undefined,
|
||||
remainingItems: options.queuedItems,
|
||||
shouldResetPendingModelSwitch: true,
|
||||
shouldResetToolExecutions: true,
|
||||
shouldClearDispatchPending: options.hasPendingDispatch,
|
||||
};
|
||||
}
|
||||
const nextDispatch = consumeDispatchedTelegramPrompt(
|
||||
options.queuedItems,
|
||||
options.hasPendingDispatch,
|
||||
);
|
||||
return {
|
||||
activeTurn: nextDispatch.activeTurn,
|
||||
remainingItems: nextDispatch.remainingItems,
|
||||
shouldResetPendingModelSwitch: true,
|
||||
shouldResetToolExecutions: true,
|
||||
shouldClearDispatchPending: options.hasPendingDispatch,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNextTelegramToolExecutionCount(options: {
|
||||
hasActiveTurn: boolean;
|
||||
currentCount: number;
|
||||
event: "start" | "end";
|
||||
}): number {
|
||||
if (!options.hasActiveTurn) return options.currentCount;
|
||||
if (options.event === "start") {
|
||||
return options.currentCount + 1;
|
||||
}
|
||||
return Math.max(0, options.currentCount - 1);
|
||||
}
|
||||
|
||||
// --- Agent End Lifecycle ---
|
||||
|
||||
export interface TelegramAgentEndPlan {
|
||||
kind: "no-turn" | "aborted" | "error" | "text" | "attachments-only" | "empty";
|
||||
shouldClearPreview: boolean;
|
||||
shouldDispatchNext: boolean;
|
||||
shouldSendErrorMessage: boolean;
|
||||
shouldSendAttachmentNotice: boolean;
|
||||
}
|
||||
|
||||
export function buildTelegramAgentEndPlan(options: {
|
||||
hasTurn: boolean;
|
||||
stopReason?: string;
|
||||
hasFinalText: boolean;
|
||||
hasQueuedAttachments: boolean;
|
||||
preserveQueuedTurnsAsHistory: boolean;
|
||||
}): TelegramAgentEndPlan {
|
||||
const shouldDispatchNext = shouldDispatchAfterTelegramAgentEnd({
|
||||
hasTurn: options.hasTurn,
|
||||
stopReason: options.stopReason,
|
||||
preserveQueuedTurnsAsHistory: options.preserveQueuedTurnsAsHistory,
|
||||
});
|
||||
if (!options.hasTurn) {
|
||||
return {
|
||||
kind: "no-turn",
|
||||
shouldClearPreview: false,
|
||||
shouldDispatchNext,
|
||||
shouldSendErrorMessage: false,
|
||||
shouldSendAttachmentNotice: false,
|
||||
};
|
||||
}
|
||||
if (options.stopReason === "aborted") {
|
||||
return {
|
||||
kind: "aborted",
|
||||
shouldClearPreview: true,
|
||||
shouldDispatchNext,
|
||||
shouldSendErrorMessage: false,
|
||||
shouldSendAttachmentNotice: false,
|
||||
};
|
||||
}
|
||||
if (options.stopReason === "error") {
|
||||
return {
|
||||
kind: "error",
|
||||
shouldClearPreview: true,
|
||||
shouldDispatchNext,
|
||||
shouldSendErrorMessage: true,
|
||||
shouldSendAttachmentNotice: false,
|
||||
};
|
||||
}
|
||||
if (options.hasFinalText) {
|
||||
return {
|
||||
kind: "text",
|
||||
shouldClearPreview: false,
|
||||
shouldDispatchNext,
|
||||
shouldSendErrorMessage: false,
|
||||
shouldSendAttachmentNotice: false,
|
||||
};
|
||||
}
|
||||
if (options.hasQueuedAttachments) {
|
||||
return {
|
||||
kind: "attachments-only",
|
||||
shouldClearPreview: true,
|
||||
shouldDispatchNext,
|
||||
shouldSendErrorMessage: false,
|
||||
shouldSendAttachmentNotice: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "empty",
|
||||
shouldClearPreview: true,
|
||||
shouldDispatchNext,
|
||||
shouldSendErrorMessage: false,
|
||||
shouldSendAttachmentNotice: false,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Session Runtime ---
|
||||
|
||||
export interface TelegramPollingStartState {
|
||||
hasBotToken: boolean;
|
||||
hasPollingPromise: boolean;
|
||||
}
|
||||
|
||||
export function shouldStartTelegramPolling(
|
||||
state: TelegramPollingStartState,
|
||||
): boolean {
|
||||
return state.hasBotToken && !state.hasPollingPromise;
|
||||
}
|
||||
|
||||
export function buildTelegramSessionStartState(
|
||||
currentModel: Model<any> | undefined,
|
||||
): {
|
||||
currentTelegramModel: Model<any> | undefined;
|
||||
activeTelegramToolExecutions: number;
|
||||
pendingTelegramModelSwitch: undefined;
|
||||
nextQueuedTelegramItemOrder: number;
|
||||
nextQueuedTelegramControlOrder: number;
|
||||
telegramTurnDispatchPending: boolean;
|
||||
compactionInProgress: boolean;
|
||||
} {
|
||||
return {
|
||||
currentTelegramModel: currentModel,
|
||||
activeTelegramToolExecutions: 0,
|
||||
pendingTelegramModelSwitch: undefined,
|
||||
nextQueuedTelegramItemOrder: 0,
|
||||
nextQueuedTelegramControlOrder: 0,
|
||||
telegramTurnDispatchPending: false,
|
||||
compactionInProgress: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTelegramSessionShutdownState<TQueueItem>(): {
|
||||
queuedTelegramItems: TQueueItem[];
|
||||
nextQueuedTelegramItemOrder: number;
|
||||
nextQueuedTelegramControlOrder: number;
|
||||
nextPriorityReactionOrder: number;
|
||||
currentTelegramModel: undefined;
|
||||
activeTelegramToolExecutions: number;
|
||||
pendingTelegramModelSwitch: undefined;
|
||||
telegramTurnDispatchPending: boolean;
|
||||
compactionInProgress: boolean;
|
||||
preserveQueuedTurnsAsHistory: boolean;
|
||||
} {
|
||||
return {
|
||||
queuedTelegramItems: [],
|
||||
nextQueuedTelegramItemOrder: 0,
|
||||
nextQueuedTelegramControlOrder: 0,
|
||||
nextPriorityReactionOrder: 0,
|
||||
currentTelegramModel: undefined,
|
||||
activeTelegramToolExecutions: 0,
|
||||
pendingTelegramModelSwitch: undefined,
|
||||
telegramTurnDispatchPending: false,
|
||||
compactionInProgress: false,
|
||||
preserveQueuedTurnsAsHistory: false,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Control Runtime ---
|
||||
|
||||
export interface TelegramControlRuntimeDeps {
|
||||
ctx: ExtensionContext;
|
||||
sendTextReply: (
|
||||
chatId: number,
|
||||
replyToMessageId: number,
|
||||
text: string,
|
||||
) => Promise<number | undefined>;
|
||||
onSettled: () => void;
|
||||
}
|
||||
|
||||
export async function executeTelegramControlItemRuntime(
|
||||
item: PendingTelegramControlItem,
|
||||
deps: TelegramControlRuntimeDeps,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await item.execute(deps.ctx);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await deps.sendTextReply(
|
||||
item.chatId,
|
||||
item.replyToMessageId,
|
||||
`Telegram control action failed: ${message}`,
|
||||
);
|
||||
} finally {
|
||||
deps.onSettled();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dispatch Runtime ---
|
||||
|
||||
export interface TelegramDispatchRuntimeDeps {
|
||||
executeControlItem: (
|
||||
item: Extract<TelegramQueueDispatchAction, { kind: "control" }>["item"],
|
||||
) => void;
|
||||
onPromptDispatchStart: (chatId: number) => void;
|
||||
sendUserMessage: (
|
||||
content: Extract<
|
||||
TelegramQueueDispatchAction,
|
||||
{ kind: "prompt" }
|
||||
>["item"]["content"],
|
||||
) => void;
|
||||
onPromptDispatchFailure: (message: string) => void;
|
||||
onIdle: () => void;
|
||||
}
|
||||
|
||||
export function executeTelegramQueueDispatchPlan(
|
||||
plan: TelegramQueueDispatchAction,
|
||||
deps: TelegramDispatchRuntimeDeps,
|
||||
): void {
|
||||
if (plan.kind === "none") {
|
||||
deps.onIdle();
|
||||
return;
|
||||
}
|
||||
if (plan.kind === "control") {
|
||||
deps.executeControlItem(plan.item);
|
||||
return;
|
||||
}
|
||||
deps.onPromptDispatchStart(plan.item.chatId);
|
||||
try {
|
||||
deps.sendUserMessage(plan.item.content);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.onPromptDispatchFailure(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Telegram extension registration helpers
|
||||
* Owns tool, command, and lifecycle-hook registration so index.ts can stay focused on runtime orchestration state and side effects
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
ExtensionContext,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import { queueTelegramAttachments } from "./attachments.ts";
|
||||
import type { PendingTelegramTurn } from "./queue.ts";
|
||||
|
||||
// --- Tool Registration ---
|
||||
|
||||
export interface TelegramAttachmentToolRegistrationDeps {
|
||||
maxAttachmentsPerTurn: number;
|
||||
getActiveTurn: () => PendingTelegramTurn | undefined;
|
||||
statPath: (path: string) => Promise<{ isFile(): boolean }>;
|
||||
}
|
||||
|
||||
export function registerTelegramAttachmentTool(
|
||||
pi: ExtensionAPI,
|
||||
deps: TelegramAttachmentToolRegistrationDeps,
|
||||
): void {
|
||||
pi.registerTool({
|
||||
name: "telegram_attach",
|
||||
label: "Telegram Attach",
|
||||
description:
|
||||
"Queue one or more local files to be sent with the next Telegram reply.",
|
||||
promptSnippet: "Queue local files to be sent with the next Telegram reply.",
|
||||
promptGuidelines: [
|
||||
"When handling a [telegram] message and the user asked for a file or generated artifact, call telegram_attach with the local path instead of only mentioning the path in text.",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
paths: Type.Array(
|
||||
Type.String({ description: "Local file path to attach" }),
|
||||
{ minItems: 1, maxItems: deps.maxAttachmentsPerTurn },
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
return queueTelegramAttachments({
|
||||
activeTurn: deps.getActiveTurn(),
|
||||
paths: params.paths,
|
||||
maxAttachmentsPerTurn: deps.maxAttachmentsPerTurn,
|
||||
statPath: deps.statPath,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Command Registration ---
|
||||
|
||||
export interface TelegramCommandRegistrationDeps {
|
||||
promptForConfig: (ctx: ExtensionCommandContext) => Promise<void>;
|
||||
getStatusLines: () => string[];
|
||||
reloadConfig: () => Promise<void>;
|
||||
hasBotToken: () => boolean;
|
||||
startPolling: (ctx: ExtensionCommandContext) => Promise<void>;
|
||||
stopPolling: () => Promise<void>;
|
||||
updateStatus: (ctx: ExtensionCommandContext) => void;
|
||||
}
|
||||
|
||||
export function registerTelegramCommands(
|
||||
pi: ExtensionAPI,
|
||||
deps: TelegramCommandRegistrationDeps,
|
||||
): void {
|
||||
pi.registerCommand("telegram-setup", {
|
||||
description: "Configure Telegram bot token",
|
||||
handler: async (_args, ctx) => {
|
||||
await deps.promptForConfig(ctx);
|
||||
},
|
||||
});
|
||||
pi.registerCommand("telegram-status", {
|
||||
description: "Show Telegram bridge status",
|
||||
handler: async (_args, ctx) => {
|
||||
ctx.ui.notify(deps.getStatusLines().join(" | "), "info");
|
||||
},
|
||||
});
|
||||
pi.registerCommand("telegram-connect", {
|
||||
description: "Start the Telegram bridge in this pi session",
|
||||
handler: async (_args, ctx) => {
|
||||
await deps.reloadConfig();
|
||||
if (!deps.hasBotToken()) {
|
||||
await deps.promptForConfig(ctx);
|
||||
return;
|
||||
}
|
||||
await deps.startPolling(ctx);
|
||||
deps.updateStatus(ctx);
|
||||
},
|
||||
});
|
||||
pi.registerCommand("telegram-disconnect", {
|
||||
description: "Stop the Telegram bridge in this pi session",
|
||||
handler: async (_args, ctx) => {
|
||||
await deps.stopPolling();
|
||||
deps.updateStatus(ctx);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Lifecycle Hook Registration ---
|
||||
|
||||
export interface TelegramLifecycleRegistrationDeps {
|
||||
onSessionStart: (event: unknown, ctx: ExtensionContext) => Promise<void>;
|
||||
onSessionShutdown: (event: unknown, ctx: ExtensionContext) => Promise<void>;
|
||||
onBeforeAgentStart: (
|
||||
event: unknown,
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<unknown> | unknown;
|
||||
onModelSelect: (
|
||||
event: unknown,
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<void> | void;
|
||||
onAgentStart: (event: unknown, ctx: ExtensionContext) => Promise<void>;
|
||||
onToolExecutionStart: (
|
||||
event: unknown,
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<void> | void;
|
||||
onToolExecutionEnd: (
|
||||
event: unknown,
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<void> | void;
|
||||
onMessageStart: (event: unknown, ctx: ExtensionContext) => Promise<void>;
|
||||
onMessageUpdate: (event: unknown, ctx: ExtensionContext) => Promise<void>;
|
||||
onAgentEnd: (event: unknown, ctx: ExtensionContext) => Promise<void>;
|
||||
}
|
||||
|
||||
export function registerTelegramLifecycleHooks(
|
||||
pi: ExtensionAPI,
|
||||
deps: TelegramLifecycleRegistrationDeps,
|
||||
): void {
|
||||
pi.on("session_start", async (event, ctx) => {
|
||||
await deps.onSessionStart(event, ctx);
|
||||
});
|
||||
pi.on("session_shutdown", async (event, ctx) => {
|
||||
await deps.onSessionShutdown(event, ctx);
|
||||
});
|
||||
pi.on("before_agent_start", (async (event: unknown, ctx: ExtensionContext) =>
|
||||
deps.onBeforeAgentStart(event, ctx)) as never);
|
||||
pi.on("model_select", async (event, ctx) => {
|
||||
await deps.onModelSelect(event, ctx);
|
||||
});
|
||||
pi.on("agent_start", async (event, ctx) => {
|
||||
await deps.onAgentStart(event, ctx);
|
||||
});
|
||||
pi.on("tool_execution_start", async (event, ctx) => {
|
||||
await deps.onToolExecutionStart(event, ctx);
|
||||
});
|
||||
pi.on("tool_execution_end", async (event, ctx) => {
|
||||
await deps.onToolExecutionEnd(event, ctx);
|
||||
});
|
||||
pi.on("message_start", async (event, ctx) => {
|
||||
await deps.onMessageStart(event, ctx);
|
||||
});
|
||||
pi.on("message_update", async (event, ctx) => {
|
||||
await deps.onMessageUpdate(event, ctx);
|
||||
});
|
||||
pi.on("agent_end", async (event, ctx) => {
|
||||
await deps.onAgentEnd(event, ctx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* Telegram preview and markdown rendering helpers
|
||||
* Converts assistant output into Telegram-safe plain text and HTML chunks with chunk-boundary handling
|
||||
*/
|
||||
|
||||
export const MAX_MESSAGE_LENGTH = 4096;
|
||||
|
||||
// --- Escaping ---
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// --- Plain Preview Rendering ---
|
||||
|
||||
function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] {
|
||||
if (line.length <= maxLength) return [line];
|
||||
const words = line.split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return [line];
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
for (const word of words) {
|
||||
const candidate = current.length === 0 ? word : `${current} ${word}`;
|
||||
if (candidate.length <= maxLength) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
if (current.length > 0) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
}
|
||||
if (word.length <= maxLength) {
|
||||
current = word;
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < word.length; i += maxLength) {
|
||||
parts.push(word.slice(i, i + maxLength));
|
||||
}
|
||||
}
|
||||
if (current.length > 0) {
|
||||
parts.push(current);
|
||||
}
|
||||
return parts.length > 0 ? parts : [line];
|
||||
}
|
||||
|
||||
function stripInlineMarkdownToPlainText(text: string): string {
|
||||
let result = text;
|
||||
result = result.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, "$1");
|
||||
result = result.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, "$1");
|
||||
result = result.replace(/<((?:https?:\/\/|mailto:)[^>]+)>/g, "$1");
|
||||
result = result.replace(/`([^`\n]+)`/g, "$1");
|
||||
result = result.replace(/(\*\*\*|___)(.+?)\1/g, "$2");
|
||||
result = result.replace(/(\*\*|__)(.+?)\1/g, "$2");
|
||||
result = result.replace(/(\*|_)(.+?)\1/g, "$2");
|
||||
result = result.replace(/~~(.+?)~~/g, "$1");
|
||||
result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1");
|
||||
return result;
|
||||
}
|
||||
|
||||
function isMarkdownTableSeparator(line: string): boolean {
|
||||
return /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
|
||||
}
|
||||
|
||||
function parseMarkdownFence(
|
||||
line: string,
|
||||
): { marker: "`" | "~"; length: number; info?: string } | undefined {
|
||||
const match = line.match(/^\s*([`~]{3,})(.*)$/);
|
||||
if (!match) return undefined;
|
||||
const fence = match[1] ?? "";
|
||||
const marker = fence[0];
|
||||
if ((marker !== "`" && marker !== "~") || /[^`~]/.test(fence)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!fence.split("").every((char) => char === marker)) return undefined;
|
||||
return {
|
||||
marker,
|
||||
length: fence.length,
|
||||
info: (match[2] ?? "").trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function isFencedCodeStart(line: string): boolean {
|
||||
return parseMarkdownFence(line) !== undefined;
|
||||
}
|
||||
|
||||
function isMatchingMarkdownFence(
|
||||
line: string,
|
||||
fence: { marker: "`" | "~"; length: number },
|
||||
): boolean {
|
||||
const match = line.match(/^\s*([`~]{3,})\s*$/);
|
||||
if (!match) return false;
|
||||
const candidate = match[1] ?? "";
|
||||
return (
|
||||
candidate.length >= fence.length &&
|
||||
candidate[0] === fence.marker &&
|
||||
candidate.split("").every((char) => char === fence.marker)
|
||||
);
|
||||
}
|
||||
|
||||
function isIndentedCodeLine(line: string): boolean {
|
||||
return /^(?:\t| {4,})/.test(line);
|
||||
}
|
||||
|
||||
function isIndentedMarkdownStructureLine(line: string): boolean {
|
||||
const trimmed = line.trimStart();
|
||||
return (
|
||||
/^(?:[-*+]|\d+\.)\s+\[([ xX])\]\s+/.test(trimmed) ||
|
||||
/^(?:[-*+]|\d+\.)\s+/.test(trimmed) ||
|
||||
/^>\s?/.test(trimmed) ||
|
||||
/^#{1,6}\s+/.test(trimmed) ||
|
||||
parseMarkdownFence(trimmed) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function canStartIndentedCodeBlock(lines: string[], index: number): boolean {
|
||||
const line = lines[index] ?? "";
|
||||
if (!isIndentedCodeLine(line)) return false;
|
||||
if (isIndentedMarkdownStructureLine(line)) return false;
|
||||
if (index === 0) return true;
|
||||
return (lines[index - 1] ?? "").trim().length === 0;
|
||||
}
|
||||
|
||||
function stripIndentedCodePrefix(line: string): string {
|
||||
if (line.startsWith("\t")) return line.slice(1);
|
||||
if (line.startsWith(" ")) return line.slice(4);
|
||||
return line;
|
||||
}
|
||||
|
||||
export function renderMarkdownPreviewText(markdown: string): string {
|
||||
const normalized = markdown.replace(/\r\n/g, "\n").trim();
|
||||
if (normalized.length === 0) return "";
|
||||
const output: string[] = [];
|
||||
const lines = normalized.split("\n");
|
||||
let activeFence: { marker: "`" | "~"; length: number } | undefined;
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine ?? "";
|
||||
const fence = parseMarkdownFence(line);
|
||||
if (activeFence) {
|
||||
if (fence && isMatchingMarkdownFence(line, activeFence)) {
|
||||
activeFence = undefined;
|
||||
continue;
|
||||
}
|
||||
if (line.trim().length === 0) {
|
||||
if (output.at(-1) !== "") output.push("");
|
||||
continue;
|
||||
}
|
||||
output.push(line);
|
||||
continue;
|
||||
}
|
||||
if (fence) {
|
||||
activeFence = { marker: fence.marker, length: fence.length };
|
||||
continue;
|
||||
}
|
||||
if (line.trim().length === 0) {
|
||||
if (output.at(-1) !== "") output.push("");
|
||||
continue;
|
||||
}
|
||||
if (isMarkdownTableSeparator(line)) {
|
||||
continue;
|
||||
}
|
||||
const heading = line.match(/^\s*#{1,6}\s+(.+)$/);
|
||||
if (heading) {
|
||||
output.push(stripInlineMarkdownToPlainText(heading[1] ?? ""));
|
||||
continue;
|
||||
}
|
||||
const task = line.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
|
||||
if (task) {
|
||||
const indent = " ".repeat((task[1] ?? "").length);
|
||||
const marker = (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
|
||||
output.push(
|
||||
`${indent}${marker} ${stripInlineMarkdownToPlainText(task[4] ?? "")}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const bullet = line.match(/^(\s*)[-*+]\s+(.+)$/);
|
||||
if (bullet) {
|
||||
output.push(
|
||||
`${" ".repeat((bullet[1] ?? "").length)}- ${stripInlineMarkdownToPlainText(bullet[2] ?? "")}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const numbered = line.match(/^(\s*\d+\.)\s+(.+)$/);
|
||||
if (numbered) {
|
||||
output.push(
|
||||
`${numbered[1]} ${stripInlineMarkdownToPlainText(numbered[2] ?? "")}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const quote = line.match(/^\s*>\s?(.+)$/);
|
||||
if (quote) {
|
||||
output.push(`> ${stripInlineMarkdownToPlainText(quote[1] ?? "")}`);
|
||||
continue;
|
||||
}
|
||||
if (/^\s*([-*_]\s*){3,}\s*$/.test(line)) {
|
||||
output.push("────────");
|
||||
continue;
|
||||
}
|
||||
output.push(stripInlineMarkdownToPlainText(line));
|
||||
}
|
||||
return output.join("\n");
|
||||
}
|
||||
|
||||
// --- Rich Markdown Rendering ---
|
||||
|
||||
function renderDelimitedInlineStyle(
|
||||
text: string,
|
||||
delimiter: string,
|
||||
render: (content: string) => string,
|
||||
): string {
|
||||
const escapedDelimiter = delimiter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(
|
||||
`(^|[^\\p{L}\\p{N}\\\\])(${escapedDelimiter})(?=\\S)(.+?)(?<=\\S)\\2(?=[^\\p{L}\\p{N}]|$)`,
|
||||
"gu",
|
||||
);
|
||||
return text.replace(
|
||||
pattern,
|
||||
(_match, prefix: string, _wrapped: string, content: string) => {
|
||||
return `${prefix}${render(content)}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text: string): string {
|
||||
const tokens: string[] = [];
|
||||
const makeToken = (html: string): string => {
|
||||
const token = `\uE000${tokens.length}\uE001`;
|
||||
tokens.push(html);
|
||||
return token;
|
||||
};
|
||||
let result = text;
|
||||
result = result.replace(
|
||||
/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
(_match, alt: string, url: string) => {
|
||||
const label = alt.trim().length > 0 ? alt : url;
|
||||
return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`);
|
||||
},
|
||||
);
|
||||
result = result.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
(_match, label: string, url: string) => {
|
||||
return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`);
|
||||
},
|
||||
);
|
||||
result = result.replace(
|
||||
/<((?:https?:\/\/|mailto:)[^>]+)>/g,
|
||||
(_match, url: string) => {
|
||||
return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`);
|
||||
},
|
||||
);
|
||||
result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => {
|
||||
return makeToken(`<code>${escapeHtml(code)}</code>`);
|
||||
});
|
||||
result = escapeHtml(result);
|
||||
result = renderDelimitedInlineStyle(result, "***", (content) => {
|
||||
return `<b><i>${content}</i></b>`;
|
||||
});
|
||||
result = renderDelimitedInlineStyle(result, "___", (content) => {
|
||||
return `<b><i>${content}</i></b>`;
|
||||
});
|
||||
result = renderDelimitedInlineStyle(result, "~~", (content) => {
|
||||
return `<s>${content}</s>`;
|
||||
});
|
||||
result = renderDelimitedInlineStyle(result, "**", (content) => {
|
||||
return `<b>${content}</b>`;
|
||||
});
|
||||
result = renderDelimitedInlineStyle(result, "__", (content) => {
|
||||
return `<b>${content}</b>`;
|
||||
});
|
||||
result = renderDelimitedInlineStyle(result, "*", (content) => {
|
||||
return `<i>${content}</i>`;
|
||||
});
|
||||
result = renderDelimitedInlineStyle(result, "_", (content) => {
|
||||
return `<i>${content}</i>`;
|
||||
});
|
||||
result = result.replace(
|
||||
/(^|[\s>(])(\[(?: |x|X)\])(?=($|[\s<).,:;!?]))/g,
|
||||
(_match, prefix: string, checkbox: string) => {
|
||||
const normalized = checkbox.toLowerCase() === "[x]" ? "[x]" : "[ ]";
|
||||
return `${prefix}<code>${normalized}</code>`;
|
||||
},
|
||||
);
|
||||
result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1");
|
||||
return result.replace(
|
||||
/\uE000(\d+)\uE001/g,
|
||||
(_match, index: string) => tokens[Number(index)] ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
function buildListIndent(level: number): string {
|
||||
return "\u00A0".repeat(Math.max(0, level) * 2);
|
||||
}
|
||||
|
||||
function parseMarkdownTableRow(line: string): string[] {
|
||||
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
||||
return trimmed
|
||||
.split("|")
|
||||
.map((cell) => stripInlineMarkdownToPlainText(cell.trim()));
|
||||
}
|
||||
|
||||
function parseMarkdownQuoteLine(
|
||||
line: string,
|
||||
): { depth: number; content: string } | undefined {
|
||||
const match = line.match(/^\s*((?:>\s*)+)(.*)$/);
|
||||
if (!match) return undefined;
|
||||
const markers = match[1] ?? "";
|
||||
const depth = (markers.match(/>/g) ?? []).length;
|
||||
return {
|
||||
depth,
|
||||
content: match[2] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdownTextLines(block: string): string[] {
|
||||
const rendered: string[] = [];
|
||||
const lines = block.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim().length === 0) continue;
|
||||
const pieces = splitPlainMarkdownLine(line);
|
||||
for (const piece of pieces) {
|
||||
const heading = piece.match(/^(\s*)#{1,6}\s+(.+)$/);
|
||||
if (heading) {
|
||||
rendered.push(
|
||||
`${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const task = piece.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
|
||||
if (task) {
|
||||
const indent = buildListIndent(Math.floor((task[1] ?? "").length / 2));
|
||||
const marker = (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
|
||||
rendered.push(
|
||||
`${indent}<code>${marker}</code> ${renderInlineMarkdown(task[4] ?? "")}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const bullet = piece.match(/^(\s*)[-*+]\s+(.+)$/);
|
||||
if (bullet) {
|
||||
const indent = buildListIndent(
|
||||
Math.floor((bullet[1] ?? "").length / 2),
|
||||
);
|
||||
rendered.push(
|
||||
`${indent}<code>-</code> ${renderInlineMarkdown(bullet[2] ?? "")}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const numbered = piece.match(/^(\s*)(\d+)\.\s+(.+)$/);
|
||||
if (numbered) {
|
||||
const indent = buildListIndent(
|
||||
Math.floor((numbered[1] ?? "").length / 2),
|
||||
);
|
||||
rendered.push(
|
||||
`${indent}<code>${numbered[2]}.</code> ${renderInlineMarkdown(numbered[3] ?? "")}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const quote = piece.match(/^>\s?(.+)$/);
|
||||
if (quote) {
|
||||
rendered.push(
|
||||
`<blockquote>${renderInlineMarkdown(quote[1] ?? "")}</blockquote>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const trimmed = piece.trim();
|
||||
if (/^([-*_]\s*){3,}$/.test(trimmed)) {
|
||||
rendered.push("────────────");
|
||||
continue;
|
||||
}
|
||||
rendered.push(renderInlineMarkdown(piece));
|
||||
}
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function renderMarkdownCodeBlock(code: string, language?: string): string[] {
|
||||
const open = language
|
||||
? `<pre><code class="language-${escapeHtml(language)}">`
|
||||
: "<pre><code>";
|
||||
const close = "</code></pre>";
|
||||
const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length;
|
||||
const chunks: string[] = [];
|
||||
let current = "";
|
||||
const pushCurrent = (): void => {
|
||||
if (current.length === 0) return;
|
||||
chunks.push(`${open}${current}${close}`);
|
||||
current = "";
|
||||
};
|
||||
const appendEscapedLine = (escapedLine: string): void => {
|
||||
if (escapedLine.length <= maxContentLength) {
|
||||
const candidate =
|
||||
current.length === 0 ? escapedLine : `${current}\n${escapedLine}`;
|
||||
if (candidate.length <= maxContentLength) {
|
||||
current = candidate;
|
||||
return;
|
||||
}
|
||||
pushCurrent();
|
||||
current = escapedLine;
|
||||
return;
|
||||
}
|
||||
pushCurrent();
|
||||
for (let i = 0; i < escapedLine.length; i += maxContentLength) {
|
||||
chunks.push(
|
||||
`${open}${escapedLine.slice(i, i + maxContentLength)}${close}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
for (const line of code.split("\n")) {
|
||||
appendEscapedLine(escapeHtml(line));
|
||||
}
|
||||
pushCurrent();
|
||||
return chunks.length > 0 ? chunks : [`${open}${close}`];
|
||||
}
|
||||
|
||||
function renderMarkdownTableBlock(lines: string[]): string[] {
|
||||
const rows = lines.map(parseMarkdownTableRow);
|
||||
const columnCount = Math.max(...rows.map((row) => row.length), 0);
|
||||
const normalizedRows = rows.map((row) => {
|
||||
const next = [...row];
|
||||
while (next.length < columnCount) {
|
||||
next.push("");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const widths = Array.from({ length: columnCount }, (_, columnIndex) => {
|
||||
return Math.max(
|
||||
3,
|
||||
...normalizedRows.map((row) => (row[columnIndex] ?? "").length),
|
||||
);
|
||||
});
|
||||
const formatRow = (row: string[]): string => {
|
||||
return row
|
||||
.map((cell, columnIndex) => (cell ?? "").padEnd(widths[columnIndex] ?? 3))
|
||||
.join(" | ");
|
||||
};
|
||||
const separator = widths.map((width) => "-".repeat(width)).join(" | ");
|
||||
const [header, ...body] = normalizedRows;
|
||||
const tableLines = [
|
||||
formatRow(header ?? []),
|
||||
separator,
|
||||
...body.map(formatRow),
|
||||
];
|
||||
return renderMarkdownCodeBlock(tableLines.join("\n"), "markdown");
|
||||
}
|
||||
|
||||
function chunkRenderedHtmlLines(
|
||||
lines: string[],
|
||||
wrapper?: { open: string; close: string },
|
||||
): string[] {
|
||||
if (lines.length === 0) return [];
|
||||
const open = wrapper?.open ?? "";
|
||||
const close = wrapper?.close ?? "";
|
||||
const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length;
|
||||
const chunks: string[] = [];
|
||||
let current = "";
|
||||
const pushCurrent = (): void => {
|
||||
if (current.length === 0) return;
|
||||
chunks.push(`${open}${current}${close}`);
|
||||
current = "";
|
||||
};
|
||||
for (const line of lines) {
|
||||
const candidate = current.length === 0 ? line : `${current}\n${line}`;
|
||||
if (candidate.length <= maxContentLength) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
pushCurrent();
|
||||
if (line.length <= maxContentLength) {
|
||||
current = line;
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < line.length; i += maxContentLength) {
|
||||
chunks.push(`${open}${line.slice(i, i + maxContentLength)}${close}`);
|
||||
}
|
||||
}
|
||||
pushCurrent();
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function renderMarkdownTextBlock(block: string): string[] {
|
||||
return chunkRenderedHtmlLines(renderMarkdownTextLines(block));
|
||||
}
|
||||
|
||||
function renderMarkdownQuoteBlock(lines: string[]): string[] {
|
||||
const inner = lines
|
||||
.map((line) => {
|
||||
const parsed = parseMarkdownQuoteLine(line);
|
||||
if (!parsed) return line;
|
||||
const nestedIndent = "\u00A0".repeat(Math.max(0, parsed.depth - 1) * 2);
|
||||
return `${nestedIndent}${parsed.content}`;
|
||||
})
|
||||
.join("\n");
|
||||
return chunkRenderedHtmlLines(renderMarkdownTextLines(inner), {
|
||||
open: "<blockquote>",
|
||||
close: "</blockquote>",
|
||||
});
|
||||
}
|
||||
|
||||
function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
||||
const normalized = markdown.replace(/\r\n/g, "\n").trim();
|
||||
if (normalized.length === 0) return [];
|
||||
const renderedBlocks: string[] = [];
|
||||
const lines = normalized.split("\n");
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] ?? "";
|
||||
const nextLine = lines[index + 1] ?? "";
|
||||
const fence = parseMarkdownFence(line);
|
||||
if (fence) {
|
||||
index += 1;
|
||||
const codeLines: string[] = [];
|
||||
while (
|
||||
index < lines.length &&
|
||||
!isMatchingMarkdownFence(lines[index] ?? "", fence)
|
||||
) {
|
||||
codeLines.push(lines[index] ?? "");
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) {
|
||||
index += 1;
|
||||
}
|
||||
renderedBlocks.push(
|
||||
...renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
|
||||
);
|
||||
while (index < lines.length && (lines[index] ?? "").trim().length === 0) {
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line.trim().length === 0) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
|
||||
const tableLines: string[] = [line];
|
||||
index += 2;
|
||||
while (index < lines.length) {
|
||||
const tableLine = lines[index] ?? "";
|
||||
if (tableLine.trim().length === 0 || !tableLine.includes("|")) {
|
||||
break;
|
||||
}
|
||||
tableLines.push(tableLine);
|
||||
index += 1;
|
||||
}
|
||||
renderedBlocks.push(...renderMarkdownTableBlock(tableLines));
|
||||
continue;
|
||||
}
|
||||
if (canStartIndentedCodeBlock(lines, index)) {
|
||||
const codeLines: string[] = [];
|
||||
while (index < lines.length) {
|
||||
const rawLine = lines[index] ?? "";
|
||||
if (rawLine.trim().length === 0) {
|
||||
codeLines.push("");
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (!isIndentedCodeLine(rawLine)) break;
|
||||
codeLines.push(stripIndentedCodePrefix(rawLine));
|
||||
index += 1;
|
||||
}
|
||||
renderedBlocks.push(...renderMarkdownCodeBlock(codeLines.join("\n")));
|
||||
continue;
|
||||
}
|
||||
if (/^\s*>/.test(line)) {
|
||||
const quoteLines: string[] = [];
|
||||
while (index < lines.length && /^\s*>/.test(lines[index] ?? "")) {
|
||||
quoteLines.push(lines[index] ?? "");
|
||||
index += 1;
|
||||
}
|
||||
renderedBlocks.push(...renderMarkdownQuoteBlock(quoteLines));
|
||||
continue;
|
||||
}
|
||||
const textLines: string[] = [];
|
||||
while (index < lines.length) {
|
||||
const current = lines[index] ?? "";
|
||||
const following = lines[index + 1] ?? "";
|
||||
if (current.trim().length === 0) break;
|
||||
if (
|
||||
isFencedCodeStart(current) ||
|
||||
canStartIndentedCodeBlock(lines, index) ||
|
||||
/^\s*>/.test(current)
|
||||
)
|
||||
break;
|
||||
if (current.includes("|") && isMarkdownTableSeparator(following)) break;
|
||||
textLines.push(current);
|
||||
index += 1;
|
||||
}
|
||||
renderedBlocks.push(...renderMarkdownTextBlock(textLines.join("\n")));
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
let current = "";
|
||||
for (const block of renderedBlocks) {
|
||||
const candidate = current.length === 0 ? block : `${current}\n\n${block}`;
|
||||
if (candidate.length <= MAX_MESSAGE_LENGTH) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
if (current.length > 0) {
|
||||
chunks.push(current);
|
||||
current = "";
|
||||
}
|
||||
if (block.length <= MAX_MESSAGE_LENGTH) {
|
||||
current = block;
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < block.length; i += MAX_MESSAGE_LENGTH) {
|
||||
chunks.push(block.slice(i, i + MAX_MESSAGE_LENGTH));
|
||||
}
|
||||
}
|
||||
if (current.length > 0) {
|
||||
chunks.push(current);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// --- Unified Telegram Rendering ---
|
||||
|
||||
export type TelegramRenderMode = "plain" | "markdown" | "html";
|
||||
|
||||
export interface TelegramRenderedChunk {
|
||||
text: string;
|
||||
parseMode?: "HTML";
|
||||
}
|
||||
|
||||
function chunkParagraphs(text: string): string[] {
|
||||
if (text.length <= MAX_MESSAGE_LENGTH) return [text];
|
||||
const normalized = text.replace(/\r\n/g, "\n");
|
||||
const paragraphs = normalized.split(/\n\n+/);
|
||||
const chunks: string[] = [];
|
||||
let current = "";
|
||||
const flushCurrent = (): void => {
|
||||
if (current.trim().length > 0) chunks.push(current);
|
||||
current = "";
|
||||
};
|
||||
const splitLongBlock = (block: string): string[] => {
|
||||
if (block.length <= MAX_MESSAGE_LENGTH) return [block];
|
||||
const lines = block.split("\n");
|
||||
const lineChunks: string[] = [];
|
||||
let lineCurrent = "";
|
||||
for (const line of lines) {
|
||||
const candidate =
|
||||
lineCurrent.length === 0 ? line : `${lineCurrent}\n${line}`;
|
||||
if (candidate.length <= MAX_MESSAGE_LENGTH) {
|
||||
lineCurrent = candidate;
|
||||
continue;
|
||||
}
|
||||
if (lineCurrent.length > 0) {
|
||||
lineChunks.push(lineCurrent);
|
||||
lineCurrent = "";
|
||||
}
|
||||
if (line.length <= MAX_MESSAGE_LENGTH) {
|
||||
lineCurrent = line;
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < line.length; i += MAX_MESSAGE_LENGTH) {
|
||||
lineChunks.push(line.slice(i, i + MAX_MESSAGE_LENGTH));
|
||||
}
|
||||
}
|
||||
if (lineCurrent.length > 0) {
|
||||
lineChunks.push(lineCurrent);
|
||||
}
|
||||
return lineChunks;
|
||||
};
|
||||
for (const paragraph of paragraphs) {
|
||||
if (paragraph.length === 0) continue;
|
||||
const parts = splitLongBlock(paragraph);
|
||||
for (const part of parts) {
|
||||
const candidate = current.length === 0 ? part : `${current}\n\n${part}`;
|
||||
if (candidate.length <= MAX_MESSAGE_LENGTH) {
|
||||
current = candidate;
|
||||
} else {
|
||||
flushCurrent();
|
||||
current = part;
|
||||
}
|
||||
}
|
||||
}
|
||||
flushCurrent();
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function renderTelegramMessage(
|
||||
text: string,
|
||||
options?: { mode?: TelegramRenderMode },
|
||||
): TelegramRenderedChunk[] {
|
||||
const mode = options?.mode ?? "plain";
|
||||
if (mode === "plain") {
|
||||
return chunkParagraphs(text).map((chunk) => ({ text: chunk }));
|
||||
}
|
||||
if (mode === "html") {
|
||||
return [{ text, parseMode: "HTML" }];
|
||||
}
|
||||
return renderMarkdownToTelegramHtmlChunks(text).map((chunk) => ({
|
||||
text: chunk,
|
||||
parseMode: "HTML",
|
||||
}));
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Telegram reply and preview domain helpers
|
||||
* Owns preview text decisions, preview runtime behavior, rendered-message delivery, and plain or markdown reply sending
|
||||
*/
|
||||
|
||||
import type { TelegramRenderedChunk, TelegramRenderMode } from "./rendering.ts";
|
||||
|
||||
// --- Preview ---
|
||||
|
||||
export interface TelegramPreviewStateLike {
|
||||
mode: "draft" | "message";
|
||||
draftId?: number;
|
||||
messageId?: number;
|
||||
pendingText: string;
|
||||
lastSentText: string;
|
||||
}
|
||||
|
||||
export interface TelegramPreviewRuntimeState extends TelegramPreviewStateLike {
|
||||
flushTimer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export interface TelegramPreviewRuntimeDeps {
|
||||
getState: () => TelegramPreviewRuntimeState | undefined;
|
||||
setState: (state: TelegramPreviewRuntimeState | undefined) => void;
|
||||
clearScheduledFlush: (state: TelegramPreviewRuntimeState) => void;
|
||||
maxMessageLength: number;
|
||||
renderPreviewText: (markdown: string) => string;
|
||||
getDraftSupport: () => "unknown" | "supported" | "unsupported";
|
||||
setDraftSupport: (support: "unknown" | "supported" | "unsupported") => void;
|
||||
allocateDraftId: () => number;
|
||||
sendDraft: (chatId: number, draftId: number, text: string) => Promise<void>;
|
||||
sendMessage: (
|
||||
chatId: number,
|
||||
text: string,
|
||||
) => Promise<TelegramSentMessageLike>;
|
||||
editMessageText: (
|
||||
chatId: number,
|
||||
messageId: number,
|
||||
text: string,
|
||||
) => Promise<void>;
|
||||
renderTelegramMessage: (
|
||||
text: string,
|
||||
options?: { mode?: TelegramRenderMode },
|
||||
) => TelegramRenderedChunk[];
|
||||
sendRenderedChunks: (
|
||||
chatId: number,
|
||||
chunks: TelegramRenderedChunk[],
|
||||
) => Promise<number | undefined>;
|
||||
editRenderedMessage: (
|
||||
chatId: number,
|
||||
messageId: number,
|
||||
chunks: TelegramRenderedChunk[],
|
||||
) => Promise<number | undefined>;
|
||||
}
|
||||
|
||||
export function buildTelegramPreviewFlushText(options: {
|
||||
state: TelegramPreviewStateLike;
|
||||
maxMessageLength: number;
|
||||
renderPreviewText: (markdown: string) => string;
|
||||
}): string | undefined {
|
||||
const rawText = options.state.pendingText.trim();
|
||||
const previewText = options.renderPreviewText(rawText).trim();
|
||||
if (!previewText || previewText === options.state.lastSentText) {
|
||||
return undefined;
|
||||
}
|
||||
return previewText.length > options.maxMessageLength
|
||||
? previewText.slice(0, options.maxMessageLength)
|
||||
: previewText;
|
||||
}
|
||||
|
||||
export function buildTelegramPreviewFinalText(
|
||||
state: TelegramPreviewStateLike,
|
||||
): string | undefined {
|
||||
const finalText = (state.pendingText.trim() || state.lastSentText).trim();
|
||||
return finalText || undefined;
|
||||
}
|
||||
|
||||
export function shouldUseTelegramDraftPreview(options: {
|
||||
draftSupport: "unknown" | "supported" | "unsupported";
|
||||
}): boolean {
|
||||
return options.draftSupport !== "unsupported";
|
||||
}
|
||||
|
||||
export async function clearTelegramPreview(
|
||||
chatId: number,
|
||||
deps: TelegramPreviewRuntimeDeps,
|
||||
): Promise<void> {
|
||||
const state = deps.getState();
|
||||
if (!state) return;
|
||||
deps.clearScheduledFlush(state);
|
||||
deps.setState(undefined);
|
||||
if (state.mode !== "draft" || state.draftId === undefined) return;
|
||||
try {
|
||||
await deps.sendDraft(chatId, state.draftId, "");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushTelegramPreview(
|
||||
chatId: number,
|
||||
deps: TelegramPreviewRuntimeDeps,
|
||||
): Promise<void> {
|
||||
const state = deps.getState();
|
||||
if (!state) return;
|
||||
state.flushTimer = undefined;
|
||||
const truncated = buildTelegramPreviewFlushText({
|
||||
state,
|
||||
maxMessageLength: deps.maxMessageLength,
|
||||
renderPreviewText: deps.renderPreviewText,
|
||||
});
|
||||
if (!truncated) return;
|
||||
if (shouldUseTelegramDraftPreview({ draftSupport: deps.getDraftSupport() })) {
|
||||
const draftId = state.draftId ?? deps.allocateDraftId();
|
||||
state.draftId = draftId;
|
||||
try {
|
||||
await deps.sendDraft(chatId, draftId, truncated);
|
||||
deps.setDraftSupport("supported");
|
||||
state.mode = "draft";
|
||||
state.lastSentText = truncated;
|
||||
return;
|
||||
} catch {
|
||||
deps.setDraftSupport("unsupported");
|
||||
}
|
||||
}
|
||||
if (state.messageId === undefined) {
|
||||
const sent = await deps.sendMessage(chatId, truncated);
|
||||
state.messageId = sent.message_id;
|
||||
state.mode = "message";
|
||||
state.lastSentText = truncated;
|
||||
return;
|
||||
}
|
||||
await deps.editMessageText(chatId, state.messageId, truncated);
|
||||
state.mode = "message";
|
||||
state.lastSentText = truncated;
|
||||
}
|
||||
|
||||
export async function finalizeTelegramPreview(
|
||||
chatId: number,
|
||||
deps: TelegramPreviewRuntimeDeps,
|
||||
): Promise<boolean> {
|
||||
const state = deps.getState();
|
||||
if (!state) return false;
|
||||
await flushTelegramPreview(chatId, deps);
|
||||
const finalText = buildTelegramPreviewFinalText(state);
|
||||
if (!finalText) {
|
||||
await clearTelegramPreview(chatId, deps);
|
||||
return false;
|
||||
}
|
||||
if (state.mode === "draft") {
|
||||
await deps.sendMessage(chatId, finalText);
|
||||
await clearTelegramPreview(chatId, deps);
|
||||
return true;
|
||||
}
|
||||
deps.setState(undefined);
|
||||
return state.messageId !== undefined;
|
||||
}
|
||||
|
||||
export async function finalizeTelegramMarkdownPreview(
|
||||
chatId: number,
|
||||
markdown: string,
|
||||
deps: TelegramPreviewRuntimeDeps,
|
||||
): Promise<boolean> {
|
||||
const state = deps.getState();
|
||||
if (!state) return false;
|
||||
await flushTelegramPreview(chatId, deps);
|
||||
const chunks = deps.renderTelegramMessage(markdown, { mode: "markdown" });
|
||||
if (chunks.length === 0) {
|
||||
await clearTelegramPreview(chatId, deps);
|
||||
return false;
|
||||
}
|
||||
if (state.mode === "draft") {
|
||||
await deps.sendRenderedChunks(chatId, chunks);
|
||||
await clearTelegramPreview(chatId, deps);
|
||||
return true;
|
||||
}
|
||||
if (state.messageId === undefined) return false;
|
||||
await deps.editRenderedMessage(chatId, state.messageId, chunks);
|
||||
deps.setState(undefined);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Delivery ---
|
||||
|
||||
export interface TelegramSentMessageLike {
|
||||
message_id: number;
|
||||
}
|
||||
|
||||
export interface TelegramReplyDeliveryDeps<TReplyMarkup> {
|
||||
sendMessage: (body: {
|
||||
chat_id: number;
|
||||
text: string;
|
||||
parse_mode?: "HTML";
|
||||
reply_markup?: TReplyMarkup;
|
||||
}) => Promise<TelegramSentMessageLike>;
|
||||
editMessage: (body: {
|
||||
chat_id: number;
|
||||
message_id: number;
|
||||
text: string;
|
||||
parse_mode?: "HTML";
|
||||
reply_markup?: TReplyMarkup;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TelegramReplyTransport<TReplyMarkup> {
|
||||
sendRenderedChunks: (
|
||||
chatId: number,
|
||||
chunks: TelegramRenderedChunk[],
|
||||
options?: { replyMarkup?: TReplyMarkup },
|
||||
) => Promise<number | undefined>;
|
||||
editRenderedMessage: (
|
||||
chatId: number,
|
||||
messageId: number,
|
||||
chunks: TelegramRenderedChunk[],
|
||||
options?: { replyMarkup?: TReplyMarkup },
|
||||
) => Promise<number | undefined>;
|
||||
}
|
||||
|
||||
export function buildTelegramReplyTransport<TReplyMarkup>(
|
||||
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
|
||||
): TelegramReplyTransport<TReplyMarkup> {
|
||||
return {
|
||||
sendRenderedChunks: async (chatId, chunks, options) => {
|
||||
return sendTelegramRenderedChunks(chatId, chunks, deps, options);
|
||||
},
|
||||
editRenderedMessage: async (chatId, messageId, chunks, options) => {
|
||||
return editTelegramRenderedMessage(
|
||||
chatId,
|
||||
messageId,
|
||||
chunks,
|
||||
deps,
|
||||
options,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendTelegramRenderedChunks<TReplyMarkup>(
|
||||
chatId: number,
|
||||
chunks: TelegramRenderedChunk[],
|
||||
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
|
||||
options?: { replyMarkup?: TReplyMarkup },
|
||||
): Promise<number | undefined> {
|
||||
let lastMessageId: number | undefined;
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
const sent = await deps.sendMessage({
|
||||
chat_id: chatId,
|
||||
text: chunk.text,
|
||||
parse_mode: chunk.parseMode,
|
||||
reply_markup:
|
||||
index === chunks.length - 1 ? options?.replyMarkup : undefined,
|
||||
});
|
||||
lastMessageId = sent.message_id;
|
||||
}
|
||||
return lastMessageId;
|
||||
}
|
||||
|
||||
export async function editTelegramRenderedMessage<TReplyMarkup>(
|
||||
chatId: number,
|
||||
messageId: number,
|
||||
chunks: TelegramRenderedChunk[],
|
||||
deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
|
||||
options?: { replyMarkup?: TReplyMarkup },
|
||||
): Promise<number | undefined> {
|
||||
if (chunks.length === 0) return messageId;
|
||||
const [firstChunk, ...remainingChunks] = chunks;
|
||||
await deps.editMessage({
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
text: firstChunk.text,
|
||||
parse_mode: firstChunk.parseMode,
|
||||
reply_markup:
|
||||
remainingChunks.length === 0 ? options?.replyMarkup : undefined,
|
||||
});
|
||||
if (remainingChunks.length > 0) {
|
||||
return sendTelegramRenderedChunks(chatId, remainingChunks, deps, options);
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
// --- Reply Runtime ---
|
||||
|
||||
export interface TelegramReplyRuntimeDeps {
|
||||
renderTelegramMessage: (
|
||||
text: string,
|
||||
options?: { mode?: TelegramRenderMode },
|
||||
) => TelegramRenderedChunk[];
|
||||
sendRenderedChunks: (
|
||||
chunks: TelegramRenderedChunk[],
|
||||
) => Promise<number | undefined>;
|
||||
}
|
||||
|
||||
export async function sendTelegramPlainReply(
|
||||
text: string,
|
||||
deps: TelegramReplyRuntimeDeps,
|
||||
options?: { parseMode?: "HTML" },
|
||||
): Promise<number | undefined> {
|
||||
const chunks = deps.renderTelegramMessage(text, {
|
||||
mode: options?.parseMode === "HTML" ? "html" : "plain",
|
||||
});
|
||||
return deps.sendRenderedChunks(chunks);
|
||||
}
|
||||
|
||||
export async function sendTelegramMarkdownReply(
|
||||
markdown: string,
|
||||
deps: TelegramReplyRuntimeDeps,
|
||||
): Promise<number | undefined> {
|
||||
const chunks = deps.renderTelegramMessage(markdown, { mode: "markdown" });
|
||||
if (chunks.length === 0) {
|
||||
return sendTelegramPlainReply(markdown, deps);
|
||||
}
|
||||
return deps.sendRenderedChunks(chunks);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Telegram setup prompt helpers
|
||||
* Computes token-prefill defaults and prompt mode selection for /telegram-setup
|
||||
*/
|
||||
|
||||
export interface TelegramBotTokenPromptSpec {
|
||||
method: "input" | "editor";
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER = "123456:ABCDEF...";
|
||||
export const TELEGRAM_BOT_TOKEN_ENV_VARS = [
|
||||
"TELEGRAM_BOT_TOKEN",
|
||||
"TELEGRAM_BOT_KEY",
|
||||
"TELEGRAM_TOKEN",
|
||||
"TELEGRAM_KEY",
|
||||
] as const;
|
||||
|
||||
export function getTelegramBotTokenInputDefault(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
configToken?: string,
|
||||
): string {
|
||||
const trimmedConfigToken = configToken?.trim();
|
||||
if (trimmedConfigToken) return trimmedConfigToken;
|
||||
for (const key of TELEGRAM_BOT_TOKEN_ENV_VARS) {
|
||||
const value = env[key]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER;
|
||||
}
|
||||
|
||||
export function getTelegramBotTokenPromptSpec(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
configToken?: string,
|
||||
): TelegramBotTokenPromptSpec {
|
||||
const value = getTelegramBotTokenInputDefault(env, configToken);
|
||||
return {
|
||||
method: value === TELEGRAM_BOT_TOKEN_INPUT_PLACEHOLDER ? "input" : "editor",
|
||||
value,
|
||||
};
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Telegram status rendering helpers
|
||||
* Builds usage, cost, and context summaries for the interactive Telegram status view
|
||||
*/
|
||||
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export interface TelegramUsageStats {
|
||||
totalInput: number;
|
||||
totalOutput: number;
|
||||
totalCacheRead: number;
|
||||
totalCacheWrite: number;
|
||||
totalCost: number;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function formatTokens(count: number): string {
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
||||
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
||||
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
return `${Math.round(count / 1000000)}M`;
|
||||
}
|
||||
|
||||
export function collectUsageStats(ctx: ExtensionContext): TelegramUsageStats {
|
||||
const stats: TelegramUsageStats = {
|
||||
totalInput: 0,
|
||||
totalOutput: 0,
|
||||
totalCacheRead: 0,
|
||||
totalCacheWrite: 0,
|
||||
totalCost: 0,
|
||||
};
|
||||
for (const entry of ctx.sessionManager.getEntries()) {
|
||||
if (entry.type !== "message" || entry.message.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
stats.totalInput += entry.message.usage.input;
|
||||
stats.totalOutput += entry.message.usage.output;
|
||||
stats.totalCacheRead += entry.message.usage.cacheRead;
|
||||
stats.totalCacheWrite += entry.message.usage.cacheWrite;
|
||||
stats.totalCost += entry.message.usage.cost.total;
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function buildStatusRow(label: string, value: string): string {
|
||||
return `<b>${escapeHtml(label)}:</b> <code>${escapeHtml(value)}</code>`;
|
||||
}
|
||||
|
||||
function buildUsageSummary(stats: TelegramUsageStats): string | undefined {
|
||||
const tokenParts: string[] = [];
|
||||
if (stats.totalInput) tokenParts.push(`↑${formatTokens(stats.totalInput)}`);
|
||||
if (stats.totalOutput) tokenParts.push(`↓${formatTokens(stats.totalOutput)}`);
|
||||
if (stats.totalCacheRead)
|
||||
tokenParts.push(`R${formatTokens(stats.totalCacheRead)}`);
|
||||
if (stats.totalCacheWrite)
|
||||
tokenParts.push(`W${formatTokens(stats.totalCacheWrite)}`);
|
||||
return tokenParts.length > 0 ? tokenParts.join(" ") : undefined;
|
||||
}
|
||||
|
||||
function buildCostSummary(
|
||||
stats: TelegramUsageStats,
|
||||
usesSubscription: boolean,
|
||||
): string | undefined {
|
||||
if (!stats.totalCost && !usesSubscription) return undefined;
|
||||
return `$${stats.totalCost.toFixed(3)}${usesSubscription ? " (sub)" : ""}`;
|
||||
}
|
||||
|
||||
function buildContextSummary(
|
||||
ctx: ExtensionContext,
|
||||
activeModel: Model<any> | undefined,
|
||||
): string {
|
||||
const usage = ctx.getContextUsage();
|
||||
if (!usage) return "unknown";
|
||||
const contextWindow = usage.contextWindow ?? activeModel?.contextWindow ?? 0;
|
||||
const percent = usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "?";
|
||||
return `${percent}/${formatTokens(contextWindow)}`;
|
||||
}
|
||||
|
||||
export function buildStatusHtml(
|
||||
ctx: ExtensionContext,
|
||||
activeModel: Model<any> | undefined,
|
||||
): string {
|
||||
const stats = collectUsageStats(ctx);
|
||||
const usesSubscription = activeModel
|
||||
? ctx.modelRegistry.isUsingOAuth(activeModel)
|
||||
: false;
|
||||
const lines: string[] = [];
|
||||
const usageSummary = buildUsageSummary(stats);
|
||||
const costSummary = buildCostSummary(stats, usesSubscription);
|
||||
if (usageSummary) {
|
||||
lines.push(buildStatusRow("Usage", usageSummary));
|
||||
}
|
||||
if (costSummary) {
|
||||
lines.push(buildStatusRow("Cost", costSummary));
|
||||
}
|
||||
lines.push(buildStatusRow("Context", buildContextSummary(ctx, activeModel)));
|
||||
if (lines.length === 0) {
|
||||
lines.push(buildStatusRow("Status", "No usage data yet."));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Telegram turn-building helpers
|
||||
* Owns prompt-turn summary and content construction so queued Telegram turns are assembled consistently
|
||||
*/
|
||||
|
||||
import { basename } from "node:path";
|
||||
|
||||
import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
|
||||
|
||||
import {
|
||||
collectTelegramMessageIds,
|
||||
formatTelegramHistoryText,
|
||||
} from "./media.ts";
|
||||
import type { PendingTelegramTurn } from "./queue.ts";
|
||||
|
||||
export interface TelegramTurnMessageLike {
|
||||
message_id: number;
|
||||
chat: { id: number };
|
||||
}
|
||||
|
||||
export interface DownloadedTelegramTurnFileLike {
|
||||
path: string;
|
||||
fileName: string;
|
||||
isImage: boolean;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function truncateTelegramQueueSummary(
|
||||
text: string,
|
||||
maxWords = 5,
|
||||
maxLength = 40,
|
||||
): string {
|
||||
const normalized = text.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return "";
|
||||
const words = normalized.split(" ");
|
||||
let summary = words.slice(0, maxWords).join(" ");
|
||||
if (summary.length === 0) summary = normalized;
|
||||
if (summary.length > maxLength) {
|
||||
summary = summary.slice(0, maxLength).trimEnd();
|
||||
}
|
||||
return summary.length < normalized.length || words.length > maxWords
|
||||
? `${summary}…`
|
||||
: summary;
|
||||
}
|
||||
|
||||
export function formatTelegramTurnStatusSummary(
|
||||
rawText: string,
|
||||
files: DownloadedTelegramTurnFileLike[],
|
||||
): string {
|
||||
const textSummary = truncateTelegramQueueSummary(rawText);
|
||||
if (textSummary) return textSummary;
|
||||
if (files.length === 1) {
|
||||
const fileName = basename(
|
||||
files[0]?.fileName || files[0]?.path || "attachment",
|
||||
);
|
||||
return `📎 ${truncateTelegramQueueSummary(fileName, 4, 32) || "attachment"}`;
|
||||
}
|
||||
if (files.length > 1) return `📎 ${files.length} attachments`;
|
||||
return "(empty message)";
|
||||
}
|
||||
|
||||
export function buildTelegramTurnPrompt(options: {
|
||||
telegramPrefix: string;
|
||||
rawText: string;
|
||||
files: DownloadedTelegramTurnFileLike[];
|
||||
historyTurns?: Pick<PendingTelegramTurn, "historyText">[];
|
||||
}): string {
|
||||
let prompt = options.telegramPrefix;
|
||||
if ((options.historyTurns?.length ?? 0) > 0) {
|
||||
prompt +=
|
||||
"\n\nEarlier Telegram messages arrived after an aborted turn. Treat them as prior user messages, in order:";
|
||||
for (const [index, turn] of (options.historyTurns ?? []).entries()) {
|
||||
prompt += `\n\n${index + 1}. ${turn.historyText}`;
|
||||
}
|
||||
prompt += "\n\nCurrent Telegram message:";
|
||||
}
|
||||
if (options.rawText.length > 0) {
|
||||
prompt +=
|
||||
(options.historyTurns?.length ?? 0) > 0
|
||||
? `\n${options.rawText}`
|
||||
: ` ${options.rawText}`;
|
||||
}
|
||||
if (options.files.length > 0) {
|
||||
prompt += "\n\nTelegram attachments were saved locally:";
|
||||
for (const file of options.files) {
|
||||
prompt += `\n- ${file.path}`;
|
||||
}
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export async function buildTelegramPromptTurn(options: {
|
||||
telegramPrefix: string;
|
||||
messages: TelegramTurnMessageLike[];
|
||||
historyTurns?: PendingTelegramTurn[];
|
||||
queueOrder: number;
|
||||
rawText: string;
|
||||
files: DownloadedTelegramTurnFileLike[];
|
||||
readBinaryFile: (path: string) => Promise<Uint8Array>;
|
||||
inferImageMimeType: (path: string) => string | undefined;
|
||||
}): Promise<PendingTelegramTurn> {
|
||||
const firstMessage = options.messages[0];
|
||||
if (!firstMessage) {
|
||||
throw new Error("Missing Telegram message for turn creation");
|
||||
}
|
||||
const content: Array<TextContent | ImageContent> = [
|
||||
{
|
||||
type: "text",
|
||||
text: buildTelegramTurnPrompt({
|
||||
telegramPrefix: options.telegramPrefix,
|
||||
rawText: options.rawText,
|
||||
files: options.files,
|
||||
historyTurns: options.historyTurns,
|
||||
}),
|
||||
},
|
||||
];
|
||||
for (const file of options.files) {
|
||||
if (!file.isImage) continue;
|
||||
const mediaType = file.mimeType || options.inferImageMimeType(file.path);
|
||||
if (!mediaType) continue;
|
||||
const buffer = await options.readBinaryFile(file.path);
|
||||
content.push({
|
||||
type: "image",
|
||||
data: Buffer.from(buffer).toString("base64"),
|
||||
mimeType: mediaType,
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: "prompt",
|
||||
chatId: firstMessage.chat.id,
|
||||
replyToMessageId: firstMessage.message_id,
|
||||
sourceMessageIds: collectTelegramMessageIds(options.messages),
|
||||
queueOrder: options.queueOrder,
|
||||
queueLane: "default",
|
||||
laneOrder: options.queueOrder,
|
||||
queuedAttachments: [],
|
||||
content,
|
||||
historyText: formatTelegramHistoryText(options.rawText, options.files),
|
||||
statusSummary: formatTelegramTurnStatusSummary(
|
||||
options.rawText,
|
||||
options.files,
|
||||
),
|
||||
};
|
||||
}
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Telegram updates domain helpers
|
||||
* Owns update extraction, authorization, classification, execution planning, and runtime execution for Telegram updates
|
||||
*/
|
||||
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// --- Extraction ---
|
||||
|
||||
export interface TelegramReactionTypeEmojiLike {
|
||||
type: "emoji";
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
export interface TelegramReactionTypeNonEmojiLike {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export type TelegramReactionTypeLike =
|
||||
| TelegramReactionTypeEmojiLike
|
||||
| TelegramReactionTypeNonEmojiLike;
|
||||
|
||||
export interface TelegramUpdateLike {
|
||||
deleted_business_messages?: { message_ids?: unknown };
|
||||
_: string;
|
||||
messages?: unknown;
|
||||
}
|
||||
|
||||
function isTelegramMessageIdList(value: unknown): value is number[] {
|
||||
return Array.isArray(value) && value.every((item) => Number.isInteger(item));
|
||||
}
|
||||
|
||||
export function normalizeTelegramReactionEmoji(emoji: string): string {
|
||||
return emoji.replace(/\uFE0F/g, "");
|
||||
}
|
||||
|
||||
export function collectTelegramReactionEmojis(
|
||||
reactions: TelegramReactionTypeLike[],
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
reactions
|
||||
.filter(
|
||||
(reaction): reaction is TelegramReactionTypeEmojiLike =>
|
||||
reaction.type === "emoji",
|
||||
)
|
||||
.map((reaction) => normalizeTelegramReactionEmoji(reaction.emoji)),
|
||||
);
|
||||
}
|
||||
|
||||
export function extractDeletedTelegramMessageIds(
|
||||
update: TelegramUpdateLike,
|
||||
): number[] {
|
||||
const deletedBusinessMessageIds =
|
||||
update.deleted_business_messages?.message_ids;
|
||||
if (isTelegramMessageIdList(deletedBusinessMessageIds)) {
|
||||
return deletedBusinessMessageIds;
|
||||
}
|
||||
if (
|
||||
update._ === "updateDeleteMessages" &&
|
||||
isTelegramMessageIdList(update.messages)
|
||||
) {
|
||||
return update.messages;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// --- Routing ---
|
||||
|
||||
export interface TelegramUserLike {
|
||||
id: number;
|
||||
is_bot: boolean;
|
||||
}
|
||||
|
||||
export interface TelegramChatLike {
|
||||
id?: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface TelegramMessageLike {
|
||||
chat: TelegramChatLike;
|
||||
from?: TelegramUserLike;
|
||||
message_id?: number;
|
||||
}
|
||||
|
||||
export interface TelegramCallbackQueryLike {
|
||||
id?: string;
|
||||
from: TelegramUserLike;
|
||||
message?: TelegramMessageLike;
|
||||
}
|
||||
|
||||
export interface TelegramUpdateRoutingLike {
|
||||
message?: TelegramMessageLike;
|
||||
edited_message?: TelegramMessageLike;
|
||||
callback_query?: TelegramCallbackQueryLike;
|
||||
}
|
||||
|
||||
export type TelegramAuthorizationState =
|
||||
| { kind: "pair"; userId: number }
|
||||
| { kind: "allow" }
|
||||
| { kind: "deny" };
|
||||
|
||||
export function getTelegramAuthorizationState(
|
||||
userId: number,
|
||||
allowedUserId?: number,
|
||||
): TelegramAuthorizationState {
|
||||
if (allowedUserId === undefined) {
|
||||
return { kind: "pair", userId };
|
||||
}
|
||||
if (userId === allowedUserId) {
|
||||
return { kind: "allow" };
|
||||
}
|
||||
return { kind: "deny" };
|
||||
}
|
||||
|
||||
export function getAuthorizedTelegramCallbackQuery(
|
||||
update: TelegramUpdateRoutingLike,
|
||||
): TelegramCallbackQueryLike | undefined {
|
||||
const query = update.callback_query;
|
||||
if (!query) return undefined;
|
||||
const message = query.message;
|
||||
if (!message || message.chat.type !== "private" || query.from.is_bot) {
|
||||
return undefined;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
export function getAuthorizedTelegramMessage(
|
||||
update: TelegramUpdateRoutingLike,
|
||||
): TelegramMessageLike | undefined {
|
||||
const message = update.message || update.edited_message;
|
||||
if (
|
||||
!message ||
|
||||
message.chat.type !== "private" ||
|
||||
!message.from ||
|
||||
message.from.is_bot
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
// --- Flow ---
|
||||
|
||||
export interface TelegramMessageReactionUpdatedLike {
|
||||
chat: { type: string };
|
||||
user?: TelegramUserLike;
|
||||
}
|
||||
|
||||
export interface TelegramUpdateFlowLike
|
||||
extends TelegramUpdateRoutingLike, TelegramUpdateLike {
|
||||
message_reaction?: TelegramMessageReactionUpdatedLike;
|
||||
}
|
||||
|
||||
export type TelegramUpdateFlowAction =
|
||||
| { kind: "ignore" }
|
||||
| { kind: "deleted"; messageIds: number[] }
|
||||
| { kind: "reaction"; reactionUpdate: TelegramMessageReactionUpdatedLike }
|
||||
| {
|
||||
kind: "callback";
|
||||
query: TelegramCallbackQueryLike;
|
||||
authorization: TelegramAuthorizationState;
|
||||
}
|
||||
| {
|
||||
kind: "message";
|
||||
message: TelegramMessageLike & { from: TelegramUserLike };
|
||||
authorization: TelegramAuthorizationState;
|
||||
};
|
||||
|
||||
export function buildTelegramUpdateFlowAction(
|
||||
update: TelegramUpdateFlowLike,
|
||||
allowedUserId?: number,
|
||||
): TelegramUpdateFlowAction {
|
||||
const deletedMessageIds = extractDeletedTelegramMessageIds(update);
|
||||
if (deletedMessageIds.length > 0) {
|
||||
return { kind: "deleted", messageIds: deletedMessageIds };
|
||||
}
|
||||
if (update.message_reaction) {
|
||||
return { kind: "reaction", reactionUpdate: update.message_reaction };
|
||||
}
|
||||
const query = getAuthorizedTelegramCallbackQuery(update);
|
||||
if (query) {
|
||||
return {
|
||||
kind: "callback",
|
||||
query,
|
||||
authorization: getTelegramAuthorizationState(
|
||||
query.from.id,
|
||||
allowedUserId,
|
||||
),
|
||||
};
|
||||
}
|
||||
const message = getAuthorizedTelegramMessage(update);
|
||||
if (message?.from) {
|
||||
return {
|
||||
kind: "message",
|
||||
message: message as TelegramMessageLike & { from: TelegramUserLike },
|
||||
authorization: getTelegramAuthorizationState(
|
||||
message.from.id,
|
||||
allowedUserId,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
|
||||
// --- Execution Planning ---
|
||||
|
||||
export type TelegramUpdateExecutionPlan =
|
||||
| { kind: "ignore" }
|
||||
| { kind: "deleted"; messageIds: number[] }
|
||||
| {
|
||||
kind: "reaction";
|
||||
reactionUpdate: NonNullable<TelegramUpdateFlowLike["message_reaction"]>;
|
||||
}
|
||||
| {
|
||||
kind: "callback";
|
||||
query: TelegramCallbackQueryLike;
|
||||
shouldPair: boolean;
|
||||
shouldDeny: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "message";
|
||||
message: TelegramMessageLike & { from: TelegramUserLike };
|
||||
shouldPair: boolean;
|
||||
shouldNotifyPaired: boolean;
|
||||
shouldDeny: boolean;
|
||||
};
|
||||
|
||||
export function buildTelegramUpdateExecutionPlan(
|
||||
action: TelegramUpdateFlowAction,
|
||||
): TelegramUpdateExecutionPlan {
|
||||
switch (action.kind) {
|
||||
case "ignore":
|
||||
return { kind: "ignore" };
|
||||
case "deleted":
|
||||
return { kind: "deleted", messageIds: action.messageIds };
|
||||
case "reaction":
|
||||
return { kind: "reaction", reactionUpdate: action.reactionUpdate };
|
||||
case "callback":
|
||||
return {
|
||||
kind: "callback",
|
||||
query: action.query,
|
||||
shouldPair: action.authorization.kind === "pair",
|
||||
shouldDeny: action.authorization.kind === "deny",
|
||||
};
|
||||
case "message":
|
||||
return {
|
||||
kind: "message",
|
||||
message: action.message,
|
||||
shouldPair: action.authorization.kind === "pair",
|
||||
shouldNotifyPaired: action.authorization.kind === "pair",
|
||||
shouldDeny: action.authorization.kind === "deny",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTelegramUpdateExecutionPlanFromUpdate(
|
||||
update: TelegramUpdateFlowLike,
|
||||
allowedUserId?: number,
|
||||
): TelegramUpdateExecutionPlan {
|
||||
return buildTelegramUpdateExecutionPlan(
|
||||
buildTelegramUpdateFlowAction(update, allowedUserId),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Runtime ---
|
||||
|
||||
export interface TelegramUpdateRuntimeDeps {
|
||||
ctx: ExtensionContext;
|
||||
removePendingMediaGroupMessages: (messageIds: number[]) => void;
|
||||
removeQueuedTelegramTurnsByMessageIds: (
|
||||
messageIds: number[],
|
||||
ctx: ExtensionContext,
|
||||
) => number;
|
||||
handleAuthorizedTelegramReactionUpdate: (
|
||||
reactionUpdate: NonNullable<
|
||||
Extract<
|
||||
TelegramUpdateExecutionPlan,
|
||||
{ kind: "reaction" }
|
||||
>["reactionUpdate"]
|
||||
>,
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<void>;
|
||||
pairTelegramUserIfNeeded: (
|
||||
userId: number,
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<boolean>;
|
||||
answerCallbackQuery: (
|
||||
callbackQueryId: string,
|
||||
text?: string,
|
||||
) => Promise<void>;
|
||||
handleAuthorizedTelegramCallbackQuery: (
|
||||
query: Extract<TelegramUpdateExecutionPlan, { kind: "callback" }>["query"],
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<void>;
|
||||
sendTextReply: (
|
||||
chatId: number,
|
||||
replyToMessageId: number,
|
||||
text: string,
|
||||
) => Promise<number | undefined>;
|
||||
handleAuthorizedTelegramMessage: (
|
||||
message: Extract<
|
||||
TelegramUpdateExecutionPlan,
|
||||
{ kind: "message" }
|
||||
>["message"],
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
function getTelegramCallbackQueryId(
|
||||
query: TelegramCallbackQueryLike,
|
||||
): string | undefined {
|
||||
return typeof query.id === "string" ? query.id : undefined;
|
||||
}
|
||||
|
||||
function getTelegramMessageReplyTarget(
|
||||
message: TelegramMessageLike,
|
||||
): { chatId: number; messageId: number } | undefined {
|
||||
if (
|
||||
typeof message.chat.id !== "number" ||
|
||||
typeof message.message_id !== "number"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
chatId: message.chat.id,
|
||||
messageId: message.message_id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeTelegramUpdate(
|
||||
update: TelegramUpdateFlowLike,
|
||||
allowedUserId: number | undefined,
|
||||
deps: TelegramUpdateRuntimeDeps,
|
||||
): Promise<void> {
|
||||
await executeTelegramUpdatePlan(
|
||||
buildTelegramUpdateExecutionPlanFromUpdate(update, allowedUserId),
|
||||
deps,
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeTelegramUpdatePlan(
|
||||
plan: TelegramUpdateExecutionPlan,
|
||||
deps: TelegramUpdateRuntimeDeps,
|
||||
): Promise<void> {
|
||||
if (plan.kind === "ignore") return;
|
||||
if (plan.kind === "deleted") {
|
||||
deps.removePendingMediaGroupMessages(plan.messageIds);
|
||||
deps.removeQueuedTelegramTurnsByMessageIds(plan.messageIds, deps.ctx);
|
||||
return;
|
||||
}
|
||||
if (plan.kind === "reaction") {
|
||||
await deps.handleAuthorizedTelegramReactionUpdate(
|
||||
plan.reactionUpdate,
|
||||
deps.ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (plan.kind === "callback") {
|
||||
if (plan.shouldPair) {
|
||||
await deps.pairTelegramUserIfNeeded(plan.query.from.id, deps.ctx);
|
||||
}
|
||||
if (plan.shouldDeny) {
|
||||
const callbackQueryId = getTelegramCallbackQueryId(plan.query);
|
||||
if (callbackQueryId) {
|
||||
await deps.answerCallbackQuery(
|
||||
callbackQueryId,
|
||||
"This bot is not authorized for your account.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await deps.handleAuthorizedTelegramCallbackQuery(plan.query, deps.ctx);
|
||||
return;
|
||||
}
|
||||
const pairedNow = plan.shouldPair
|
||||
? await deps.pairTelegramUserIfNeeded(plan.message.from.id, deps.ctx)
|
||||
: false;
|
||||
const replyTarget = getTelegramMessageReplyTarget(plan.message);
|
||||
if (pairedNow && plan.shouldNotifyPaired && replyTarget) {
|
||||
await deps.sendTextReply(
|
||||
replyTarget.chatId,
|
||||
replyTarget.messageId,
|
||||
"Telegram bridge paired with this account.",
|
||||
);
|
||||
}
|
||||
if (plan.shouldDeny) {
|
||||
if (replyTarget) {
|
||||
await deps.sendTextReply(
|
||||
replyTarget.chatId,
|
||||
replyTarget.messageId,
|
||||
"This bot is not authorized for your account.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await deps.handleAuthorizedTelegramMessage(plan.message, deps.ctx);
|
||||
}
|
||||
Reference in New Issue
Block a user