0.2.0: refactor into domain modules

This commit is contained in:
LLB
2026-04-11 10:31:25 +04:00
parent 233b20b089
commit 8dcf761937
34 changed files with 10450 additions and 2672 deletions
+222
View File
@@ -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);
},
};
}
+98
View File
@@ -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
View File
@@ -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
View File
@@ -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,
);
}
+62
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+163
View File
@@ -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);
});
}
+697
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// --- 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
View File
@@ -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);
}
+41
View File
@@ -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
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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
View File
@@ -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
View File
@@ -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);
}