This commit is contained in:
2026-05-13 10:18:54 +03:00
parent cd8d2683c0
commit c5b61ee3d8
38 changed files with 3929 additions and 3718 deletions
+283
View File
@@ -0,0 +1,283 @@
import {Message} from "typescript-telegram-bot-api";
export type AiRunnerLogLevel = "trace" | "debug" | "info" | "success" | "warn" | "error";
export type AiRunnerLogDetails = Record<string, unknown>;
export type AiLogToolCallLike = {
id: string;
name: string;
argumentsText: string;
};
const AI_RUNNER_LOG_PREFIX = "unified-ai-runner";
const AI_RUNNER_LOG_MAX_STRING = 600;
const AI_RUNNER_LOG_MAX_ARRAY = 8;
const LOG_LEVEL_WEIGHT: Record<AiRunnerLogLevel, number> = {
trace: 10,
debug: 20,
info: 30,
success: 30,
warn: 40,
error: 50,
};
const AI_RUNNER_LOG_COLORS: Record<AiRunnerLogLevel | "reset" | "bold" | "dim" | "label" | "key" | "value", string> = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
trace: "\x1b[90m",
debug: "\x1b[90m",
info: "\x1b[36m",
success: "\x1b[32m",
warn: "\x1b[33m",
error: "\x1b[31m",
label: "\x1b[35m",
key: "\x1b[94m",
value: "\x1b[97m",
};
function envBool(name: string, defaultValue: boolean): boolean {
const value = process.env[name];
if (value === undefined) return defaultValue;
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
}
function aiRunnerLogsEnabled(): boolean {
return envBool("AI_RUNNER_LOGS", true) && envBool("AI_LOG_ENABLED", true);
}
function aiRunnerColorsEnabled(): boolean {
return envBool("AI_RUNNER_LOG_COLORS", true) && !process.env.NO_COLOR;
}
function configuredMinLevel(): AiRunnerLogLevel {
const raw = process.env.AI_LOG_LEVEL?.trim().toLowerCase();
if (raw && raw in LOG_LEVEL_WEIGHT) return raw as AiRunnerLogLevel;
return "debug";
}
function shouldWriteLevel(level: AiRunnerLogLevel): boolean {
return LOG_LEVEL_WEIGHT[level] >= LOG_LEVEL_WEIGHT[configuredMinLevel()];
}
function paintAiLog(value: string, color: keyof typeof AI_RUNNER_LOG_COLORS): string {
if (!aiRunnerColorsEnabled()) return value;
return `${AI_RUNNER_LOG_COLORS[color]}${value}${AI_RUNNER_LOG_COLORS.reset}`;
}
function truncateAiLogString(value: string, max = AI_RUNNER_LOG_MAX_STRING): string {
if (value.length <= max) return value;
return `${value.slice(0, max)}… (+${value.length - max} chars)`;
}
function safeJsonParseObject(value?: string): Record<string, unknown> {
if (!value?.trim()) return {};
try {
const parsed: unknown = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {};
} catch {
return {};
}
}
function isSecretKey(keyPath: string): boolean {
const normalized = keyPath.toLowerCase();
return normalized.includes("token")
|| normalized.includes("secret")
|| normalized.includes("apikey")
|| normalized.includes("api_key")
|| normalized.includes("authorization")
|| normalized.endsWith(".key")
|| normalized === "key";
}
function isPromptKey(keyPath: string): boolean {
const normalized = keyPath.toLowerCase();
return normalized.includes("prompt") || normalized.includes("systemprompt");
}
function isTextPreviewKey(keyPath: string): boolean {
const normalized = keyPath.toLowerCase();
return normalized.includes("content")
|| normalized.includes("message")
|| normalized.includes("text")
|| normalized.includes("preview")
|| normalized.includes("input")
|| normalized.includes("output");
}
function isToolArgsKey(keyPath: string): boolean {
const normalized = keyPath.toLowerCase();
return normalized.endsWith("args")
|| normalized.endsWith("arguments")
|| normalized.includes("toolargs")
|| normalized.includes("tool_args");
}
function isDaoKey(keyPath: string): boolean {
const normalized = keyPath.toLowerCase();
return normalized.includes("dao")
|| normalized.includes("database")
|| normalized.includes("db.")
|| normalized.includes("sql")
|| normalized.includes("chunk");
}
function shouldRedactKey(keyPath: string): boolean {
if (isSecretKey(keyPath)) return true;
if (isPromptKey(keyPath) && !envBool("AI_LOG_PROMPTS", false)) return true;
if (isToolArgsKey(keyPath) && !envBool("AI_LOG_TOOL_ARGS", false)) return true;
if (isDaoKey(keyPath) && !envBool("AI_LOG_DAO", false)) return true;
if (isTextPreviewKey(keyPath) && !envBool("AI_LOG_TEXT_PREVIEW", false)) return true;
return false;
}
function primitiveToLogValue(value: unknown): unknown {
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack?.split("\n").slice(0, 6).join("\n"),
};
}
if (typeof value === "string") return truncateAiLogString(value);
if (typeof value === "number" || typeof value === "boolean" || value === null || value === undefined) return value;
if (typeof value === "bigint") return value.toString();
if (typeof value === "function") return `[Function ${value.name || "anonymous"}]`;
if (Buffer.isBuffer(value)) return `<Buffer ${(value as {length: number}).length} bytes>`;
return undefined;
}
export function flattenAiLogDetails(
value: unknown,
keyPath = "",
depth = 0,
seen = new WeakSet<object>(),
): Record<string, unknown> {
if (keyPath && shouldRedactKey(keyPath)) {
return {[keyPath]: "<redacted>"};
}
const primitive = primitiveToLogValue(value);
if (primitive !== undefined || value === undefined) {
return keyPath ? {[keyPath]: primitive} : {value: primitive};
}
if (typeof value !== "object" || value === null) {
return keyPath ? {[keyPath]: String(value)} : {value: String(value)};
}
if (seen.has(value)) {
return keyPath ? {[keyPath]: "[Circular]"} : {value: "[Circular]"};
}
seen.add(value);
if (Array.isArray(value)) {
if (depth >= 2) {
return keyPath ? {[keyPath]: `[Array ${value.length}]`} : {value: `[Array ${value.length}]`};
}
const entries: Record<string, unknown> = {};
value.slice(0, AI_RUNNER_LOG_MAX_ARRAY).forEach((item, index) => {
Object.assign(entries, flattenAiLogDetails(item, keyPath ? `${keyPath}.${index}` : String(index), depth + 1, seen));
});
if (value.length > AI_RUNNER_LOG_MAX_ARRAY) {
entries[keyPath ? `${keyPath}.__more` : "__more"] = value.length - AI_RUNNER_LOG_MAX_ARRAY;
}
return entries;
}
if (depth >= 3) {
return keyPath ? {[keyPath]: "[Object]"} : {value: "[Object]"};
}
const entries: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
const childPath = keyPath ? `${keyPath}.${key}` : key;
if ((key.toLowerCase() === "data" || key.toLowerCase() === "image_url" || key.toLowerCase().endsWith("b64")) && typeof raw === "string") {
entries[childPath] = `<${raw.length} chars>`;
continue;
}
Object.assign(entries, flattenAiLogDetails(raw, childPath, depth + 1, seen));
}
return entries;
}
export function redactLogValue(value: unknown): Record<string, unknown> {
return flattenAiLogDetails(value);
}
function formatAiLogDetails(details?: AiRunnerLogDetails): string {
if (!details || !Object.keys(details).length) return "";
const flattened = flattenAiLogDetails(details);
const chunks = Object.entries(flattened).map(([key, value]) => {
const safeValue = typeof value === "string" ? value : JSON.stringify(value);
return `${paintAiLog(key, "key")}=${paintAiLog(safeValue ?? "undefined", "value")}`;
});
return ` ${chunks.join(" ")}`;
}
export function aiLog(level: AiRunnerLogLevel, event: string, details?: AiRunnerLogDetails): void {
if (!aiRunnerLogsEnabled() || !shouldWriteLevel(level)) return;
const timestamp = paintAiLog(new Date().toISOString(), "dim");
const prefix = paintAiLog(AI_RUNNER_LOG_PREFIX, "bold");
const levelText = paintAiLog(level.toUpperCase().padEnd(7), level);
const eventText = paintAiLog(event, "label");
const line = `${timestamp} ${prefix} ${levelText} ${eventText}${formatAiLogDetails(details)}`;
if (level === "error") {
console.error(line);
} else if (level === "warn") {
console.warn(line);
} else {
console.log(line);
}
}
export function aiLogDuration(startedAt: number): string {
const ms = Date.now() - startedAt;
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
export function aiLogToolCall(toolCall: AiLogToolCallLike): Record<string, unknown> {
return {
id: toolCall.id,
name: toolCall.name,
arguments: safeJsonParseObject(toolCall.argumentsText),
};
}
export function aiLogMessageIdentity(msg: Message | undefined): Record<string, unknown> | undefined {
if (!msg) return undefined;
return {
chatId: msg.chat?.id,
chatType: msg.chat?.type,
messageId: msg.message_id,
fromId: msg.from?.id,
username: msg.from?.username,
};
}
export function aiLogProviderTarget(target: {provider: string; purpose?: string; model?: string; baseUrl?: string; apiKey?: string} | undefined): Record<string, unknown> | undefined {
if (!target) return undefined;
return {
provider: target.provider,
purpose: target.purpose,
model: target.model,
baseUrl: target.baseUrl,
apiKey: target.apiKey,
};
}
+1 -1
View File
@@ -207,7 +207,7 @@ export function createMistralClient(target: AiRuntimeTarget): Mistral {
export function createOllamaClient(target: AiRuntimeTarget): Ollama {
return new Ollama({
host: target.baseUrl?.endsWith(":11434") ? target.baseUrl : target.baseUrl + ":11434",
host: target.baseUrl,
headers: target.apiKey ? {"Authorization": `Bearer ${target.apiKey}`} : undefined,
});
}
-21
View File
@@ -65,26 +65,5 @@ export function asMistralChatMessage(message: ChatMessage): MistralChatMessage {
// }
// }
/*
const messages: any[] = ordered.map(part => {
const content: any[] = [{
type: "input_text",
text: (Environment.USE_NAMES_IN_PROMPT && !part.bot ? `MESSAGE FROM USER \"${part.name}\":\n` : "") + part.content,
}];
if (!part.bot) {
for (const image of part.images ?? []) {
content.push({type: "input_image", image_url: `data:image/jpeg;base64,${image}`, detail: "auto"});
}
}
return {role: part.bot ? "assistant" : "user", content};
});
if (Environment.SYSTEM_PROMPT && Environment.USE_SYSTEM_PROMPT) {
messages.unshift({role: "system", content: Environment.SYSTEM_PROMPT});
}
return {parts: messages, imageCount};
*/
export type AiChatMessage = | OpenAIChatMessage | OllamaChatMessage | MistralChatMessage | GeminiMessage;
+1 -1
View File
@@ -67,7 +67,7 @@ export type MistralContentChunk =
export type MistralFunctionCall = {
name: string;
arguments: { [k: string]: any } | string;
arguments: Record<string, unknown> | string;
};
export type MistralToolCall = {
+6 -2
View File
@@ -1,3 +1,7 @@
import {ResponseInputItem} from "openai/resources/responses/responses";
import type {ResponseInputMessageContentList} from "openai/resources/responses/responses";
export type OpenAIChatMessage = ResponseInputItem
export type OpenAIChatMessage = {
type: "message";
role: "system" | "user" | "assistant";
content: string | ResponseInputMessageContentList;
};
+21 -8
View File
@@ -295,26 +295,38 @@ export async function formatRuntimeModelInfo(
);
}
type NamedModel = {
id?: string;
name?: string;
model?: string;
};
type ModelListResponse = {
models?: NamedModel[];
data?: NamedModel[];
};
export async function listProviderModels(provider: AiProvider): Promise<string[]> {
const target = resolveAiRuntimeTarget(provider, "chat", getRuntimeModel(provider));
switch (provider) {
case AiProvider.OLLAMA: {
const ollama = createOllamaClient(target);
const result: any = await ollama.list();
return (result.models ?? []).map((m: any) => m.model || m.name).filter(Boolean);
const result = await ollama.list() as ModelListResponse;
return (result.models ?? []).map(m => m.model || m.name).filter((name): name is string => !!name);
}
case AiProvider.GEMINI: {
const models: string[] = [];
if (getGeminiApiMode(target) === "openai") {
const geminiAi = createGeminiOpenAiClient(target);
const iterable: any = await geminiAi.models.list();
const iterable = await geminiAi.models.list() as AsyncIterable<NamedModel>;
for await (const model of iterable) models.push(model.name || model.id || String(model));
return models;
}
const geminiAi = createGoogleGenAiClient(target);
const iterable: any = await geminiAi.models.list();
const iterable = await geminiAi.models.list() as AsyncIterable<NamedModel>;
for await (const model of iterable) {
const name = model.name || model.id || String(model);
models.push(String(name).replace(/^models\//, ""));
@@ -323,13 +335,14 @@ export async function listProviderModels(provider: AiProvider): Promise<string[]
}
case AiProvider.MISTRAL: {
const mistralAi = createMistralClient(target);
const result: any = await mistralAi.models.list();
return (result.data ?? result.models ?? result ?? []).map((m: any) => m.id || m.name || String(m)).filter(Boolean);
const result = await mistralAi.models.list() as ModelListResponse | NamedModel[];
const items = Array.isArray(result) ? result : result.data ?? result.models ?? [];
return items.map(m => m.id || m.name || String(m)).filter((name): name is string => !!name);
}
case AiProvider.OPENAI: {
const openAi = createOpenAiClient(target);
const result: any = await openAi.models.list();
return (result.data ?? []).map((m: any) => m.id).filter(Boolean);
const result = await openAi.models.list() as ModelListResponse;
return (result.data ?? []).map(m => m.id).filter((id): id is string => !!id);
}
}
}
+11 -11
View File
@@ -7,11 +7,11 @@ export type AiRequestQueueTarget = {
baseUrl?: string;
};
type QueueEntry<T> = {
type QueueEntry = {
target: AiRequestQueueTarget;
queueKey: string;
run: () => Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
run: () => Promise<unknown>;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
onPositionChange: (requestsBefore: number) => Promise<void> | void;
signal?: AbortSignal;
@@ -26,7 +26,7 @@ type EnqueueOptions<T> = {
};
class AiProviderRequestQueue {
private readonly waiting = new Map<string, Array<QueueEntry<any>>>();
private readonly waiting = new Map<string, QueueEntry[]>();
private readonly active = new Map<string, number>();
enqueue<T>(target: AiRequestQueueTarget, options: EnqueueOptions<T>): Promise<T> {
@@ -36,11 +36,11 @@ class AiProviderRequestQueue {
return new Promise<T>((resolve, reject) => {
const queueKey = this.queueKey(target);
const entry: QueueEntry<T> = {
const entry: QueueEntry = {
target,
queueKey,
run: options.run,
resolve,
resolve: value => resolve(value as T),
reject,
onPositionChange: options.onPositionChange,
signal: options.signal,
@@ -63,11 +63,11 @@ class AiProviderRequestQueue {
});
}
private getQueue(queueKey: string): Array<QueueEntry<any>> | undefined {
private getQueue(queueKey: string): QueueEntry[] | undefined {
return this.waiting.get(queueKey);
}
private getOrCreateQueue(queueKey: string): Array<QueueEntry<any>> {
private getOrCreateQueue(queueKey: string): QueueEntry[] {
let queue = this.waiting.get(queueKey);
if (!queue) {
queue = [];
@@ -104,7 +104,7 @@ class AiProviderRequestQueue {
]);
}
private removeWaitingEntry(entry: QueueEntry<any>): boolean {
private removeWaitingEntry(entry: QueueEntry): boolean {
const queue = this.getQueue(entry.queueKey);
if (!queue) return false;
@@ -147,7 +147,7 @@ class AiProviderRequestQueue {
}
}
private async runEntry(entry: QueueEntry<any>): Promise<void> {
private async runEntry(entry: QueueEntry): Promise<void> {
try {
entry.resolve(await entry.run());
} catch (e) {
@@ -174,7 +174,7 @@ class AiProviderRequestQueue {
}).catch(console.error);
}
private deleteQueueIfIdle(queueKey: string, queue: Array<QueueEntry<any>>): void {
private deleteQueueIfIdle(queueKey: string, queue: QueueEntry[]): void {
if (!queue.length && this.activeCount(queueKey) <= 0) {
this.waiting.delete(queueKey);
}
+12 -10
View File
@@ -198,7 +198,7 @@ async function transcribeGeminiSpeech(audio: AiDownloadedFile, signal?: AbortSig
temperature: 0,
abortSignal: signal,
},
});
}) as unknown as GeminiSpeechResponse;
return {
provider: AiProvider.GEMINI,
@@ -240,17 +240,19 @@ async function transcribeOllamaSpeech(audio: AiDownloadedFile, signal?: AbortSig
};
}
function collectGeminiText(response: any): string {
if (typeof response?.text === "string") return response.text;
type GeminiSpeechResponse = {
text?: string;
candidates?: Array<{content?: {parts?: Array<{text?: string}>}}> ;
};
const candidates = response?.candidates ?? [];
const candidateText = candidates
.flatMap((candidate: any) => candidate?.content?.parts ?? [])
.map((part: any) => part?.text ?? "")
function collectGeminiText(response: GeminiSpeechResponse): string {
if (typeof response.text === "string") return response.text;
const candidateText = (response.candidates ?? [])
.flatMap(candidate => candidate.content?.parts ?? [])
.map(part => part.text ?? "")
.join("");
if (candidateText.trim()) return candidateText;
return (response?.candidates ?? [])
.map((output: any) => typeof output === "string" ? output : output?.content?.parts?.[0]?.text ?? "")
.join("");
return "";
}
+4 -3
View File
@@ -292,7 +292,7 @@ export class TelegramStreamMessage {
}
if (shouldRemoveKeyboard) await this.removeKeyboard();
this.lastSent = next;
} catch (e: any) {
} catch (e: unknown) {
if (shouldRemoveKeyboard && this.isMessageNotModified(e)) {
await this.removeKeyboard();
this.lastSent = next;
@@ -369,8 +369,9 @@ export class TelegramStreamMessage {
if (result && result !== true) this.waitMessage = result;
this.mediaMode = true;
this.lastSent = next;
} catch (e: any) {
if (!String(e?.message ?? e).includes("message is not modified")) logError(e);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
if (!message.includes("message is not modified")) logError(e);
}
}
+2 -2
View File
@@ -171,7 +171,7 @@ async function synthesizeMistralSpeech(text: string, voice?: string): Promise<Sy
if (target.model) request.model = target.model;
if (voice || Environment.MISTRAL_TTS_VOICE_ID) request.voiceId = voice || Environment.MISTRAL_TTS_VOICE_ID;
const response: any = await mistralAi.audio.speech.complete(request);
const response = await mistralAi.audio.speech.complete(request) as unknown as {audioData?: string; audio_data?: string};
const audioData = response?.audioData ?? response?.audio_data;
if (typeof audioData !== "string" || !audioData.trim()) {
throw new Error(Environment.mistralTtsNoAudioDataText);
@@ -192,7 +192,7 @@ async function synthesizeMistralSpeech(text: string, voice?: string): Promise<Sy
async function synthesizeGeminiSpeech(text: string, voice?: string): Promise<SynthesizedSpeech> {
const target = resolveAiRuntimeTarget(AiProvider.GEMINI, "textToSpeech");
const geminiAi = createGoogleGenAiClient(target);
const response: any = await geminiAi.models.generateContent({
const response = await geminiAi.models.generateContent({
model: target.model,
contents: text,
config: {
+9 -1
View File
@@ -15,7 +15,15 @@ export function getOpenAITools(): AiTool[] {
}));
}
export function getOpenAIResponsesTools(): any[] {
export type OpenAiResponseTool = {
type: "function";
name: string;
description?: string;
parameters?: unknown;
strict: false;
};
export function getOpenAIResponsesTools(): OpenAiResponseTool[] {
return getTools().map(tool => ({
type: "function",
name: tool.function.name,
+2 -25
View File
@@ -1,28 +1,5 @@
/*
interface Tool {
type: string;
function: {
name?: string;
description?: string;
type?: string;
parameters?: {
type?: string;
$defs?: any;
items?: any;
required?: string[];
properties?: {
[key: string]: {
type?: string | string[];
items?: any;
description?: string;
enum?: any[];
};
};
};
};
}
*/
export type AiToolParameters = {
type: "object";
@@ -45,7 +22,7 @@ export type AiToolCall = {
function: {
name: string;
arguments: {
[key: string]: any;
[key: string]: unknown;
};
};
};
+4 -3
View File
@@ -356,11 +356,12 @@ export async function webSearch(args?: Record<string, unknown>) {
note: "Use returned URLs as sources. Do not invent facts that are not present in the snippets/results.",
};
} catch (e: any) {
} catch (e: unknown) {
logError(e);
const status = e?.response?.status;
const data = e?.response?.data;
const axiosLike = e as {response?: {status?: unknown; data?: unknown}};
const status = axiosLike.response?.status;
const data = axiosLike.response?.data;
return {
ok: false,
+4 -4
View File
@@ -327,8 +327,8 @@ async function assertNoSymlinkInPath(
if (stat.isSymbolicLink()) {
throw new Error("Symlinks are not allowed in file tool paths.");
}
} catch (e: any) {
if (e?.code === "ENOENT" && options?.allowMissingTail) {
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === "ENOENT" && options?.allowMissingTail) {
return;
}
@@ -341,8 +341,8 @@ async function pathExists(absolutePath: string): Promise<boolean> {
try {
await fs.promises.lstat(absolutePath);
return true;
} catch (e: any) {
if (e?.code === "ENOENT") return false;
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === "ENOENT") return false;
throw e;
}
}
+2 -2
View File
@@ -58,11 +58,11 @@ export const marketRatesToolPrompt = [
"- When the user asks for all rates, group fiat currencies separately from crypto and gold.",
].join("\n");
export async function getMarketRates(): Promise<any | undefined> {
export async function getMarketRates(): Promise<unknown | undefined> {
try {
const response = await axios.get("https://apid.r00t.top/api/v2/currency/rates");
return response.data;
} catch (e: any) {
} catch (e: unknown) {
console.error("GET_MARKET_RATES", e);
return undefined;
}
+1 -1
View File
@@ -692,7 +692,7 @@ function parsePythonInterpreterArgs(
return {
code,
stdin,
stdin: typeof stdin === "string" ? stdin : undefined,
timeoutMs: timeoutMs === undefined ? undefined : Number(timeoutMs),
};
}
+1 -1
View File
@@ -96,7 +96,7 @@ export async function loadOllamaModel(model: string, ollama: Ollama, contextLeng
}
});
return true;
} catch (e: any) {
} catch (e: unknown) {
console.error("Error loading Ollama model:", model);
return false;
}
+2 -2
View File
@@ -42,7 +42,7 @@ export const weatherToolPrompt = [
"If the city is missing or unclear, ask the user to specify it.",
].join("\n");
export async function getWeather(args?: Record<string, unknown>): Promise<any | null> {
export async function getWeather(args?: Record<string, unknown>): Promise<Record<string, unknown> | null> {
console.log("getWeather()");
try {
const city = asNonEmptyString(args?.city);
@@ -137,7 +137,7 @@ export async function getWeather(args?: Record<string, unknown>): Promise<any |
windSpeed: wind.speed,
},
};
} catch (e: any) {
} catch (e: unknown) {
logError(e);
return null;
} finally {
+156
View File
@@ -0,0 +1,156 @@
// Gemini provider runner extracted from unified-ai-runner.ts.
import {getGeminiTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime";
import {GeminiMessage} from "./gemini-chat-message";
import {createGoogleGenAiClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "./ai-logger";
import {AsyncIterableStream, GeminiFunctionCallLike, GeminiResponseLike, MAX_TOOL_ROUNDS, RuntimeConfigSnapshot, ToolCallData, ToolExecutionMemory, executeToolBatch, roundStatus, safeJsonParseObject, GeminiGenerationRequest} from "./unified-ai-runner.shared";
function collectGeminiResponseText(response: GeminiResponseLike & { text?: string }): string {
if (typeof response.text === "string") return response.text;
return (response.candidates ?? [])
.flatMap(candidate => candidate.content?.parts ?? [])
.map(part => part.text ?? "")
.join("");
}
function collectGeminiFunctionCalls(response: GeminiResponseLike): ToolCallData[] {
const calls = response.functionCalls
?? (response.candidates ?? []).flatMap(candidate => {
return (candidate.content?.parts ?? [])
.map(part => part.functionCall)
.filter((call): call is GeminiFunctionCallLike => !!call);
});
return (calls ?? []).map((call, index) => ({
id: call.id ?? `gemini_${index}_${call.name ?? "call"}`,
name: call.name ?? "",
argumentsText: JSON.stringify(call.args ?? {}),
})).filter((call: ToolCallData) => call.name);
}
function mergeGeminiFunctionCalls(existing: ToolCallData[], next: ToolCallData[]): ToolCallData[] {
const merged = [...existing];
for (const call of next) {
const index = merged.findIndex(item => item.id === call.id);
if (index === -1) {
merged.push(call);
} else {
merged[index] = call;
}
}
return merged;
}
function appendGeminiToolRound(messages: GeminiMessage[], calls: ToolCallData[], results: string[]): void {
messages.push({
role: "model",
parts: calls.map(call => ({
functionCall: {
id: call.id,
name: call.name,
args: safeJsonParseObject(call.argumentsText),
},
})),
});
messages.push({
role: "user",
parts: calls.map((call, index) => ({
functionResponse: {
id: call.id,
name: call.name,
response: {result: results[index] ?? ""},
},
})),
});
}
export async function runGemini(
messages: GeminiMessage[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
firstRoundStatus: string,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
): Promise<void> {
const runnerStartedAt = Date.now();
const geminiAi = createGoogleGenAiClient(config.geminiChatTarget);
aiLog("info", "gemini.run.start", {
stream,
target: aiLogProviderTarget(config.geminiChatTarget),
inputMessages: messages.length,
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
const toolMemory: ToolExecutionMemory = new Map();
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const roundStartedAt = Date.now();
aiLog("debug", "gemini.round.start", {round, messages: messages.length, stream});
if (signal.aborted) throw new Error("Aborted");
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
await streamMessage.flush();
const request: GeminiGenerationRequest = {
model: config.geminiChatTarget.model,
contents: messages,
config: {
tools: getGeminiTools(),
temperature: messages.length <= 2 ? 0 : 0.6,
abortSignal: signal,
},
};
if (!stream) {
const response = await geminiAi.models.generateContent(request) as unknown as GeminiResponseLike & {
text?: string
};
const text = collectGeminiResponseText(response);
streamMessage.append(text);
const calls = collectGeminiFunctionCalls(response);
aiLog(calls.length ? "info" : "success", calls.length ? "gemini.tool_calls" : "gemini.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: text.length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
appendGeminiToolRound(messages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
continue;
}
const response = await geminiAi.models.generateContentStream(request) as unknown as AsyncIterableStream<GeminiResponseLike & {
text?: string
}>;
aiLog("debug", "gemini.stream.open", {round});
let calls: ToolCallData[] = [];
const roundTextStart = streamMessage.getText().length;
for await (const chunk of response) {
if (signal.aborted) throw new Error("Aborted");
streamMessage.append(collectGeminiResponseText(chunk));
calls = mergeGeminiFunctionCalls(calls, collectGeminiFunctionCalls(chunk));
}
aiLog(calls.length ? "info" : "success", calls.length ? "gemini.tool_calls" : "gemini.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: streamMessage.getText().slice(roundTextStart).length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
appendGeminiToolRound(messages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
}
}
export class GeminiProviderRunner {
static run = runGemini;
}
+137
View File
@@ -0,0 +1,137 @@
// Mistral provider runner extracted from unified-ai-runner.ts.
import {Environment} from "../common/environment";
import {getMistralTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime";
import {MistralChatMessage} from "./mistral-chat-message";
import {createMistralClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "./ai-logger";
import {MAX_TOOL_ROUNDS, MistralDeltaLike, MistralDocumentReference, RuntimeConfigSnapshot, StreamingToolCallAccumulator, ToolCallData, ToolExecutionMemory, contentFromMistralDelta, executeToolBatch, mistralToolCalls, normalizeMistralToolCalls, roundStatus} from "./unified-ai-runner.shared";
export async function runMistral(
messages: MistralChatMessage[],
documents: MistralDocumentReference[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
firstRoundStatus: string,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
): Promise<void> {
const runnerStartedAt = Date.now();
const mistralAi = createMistralClient(config.mistralChatTarget);
aiLog("info", "mistral.run.start", {
stream,
target: aiLogProviderTarget(config.mistralChatTarget),
inputMessages: messages.length,
documents: documents.length,
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
const toolMemory: ToolExecutionMemory = new Map();
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const roundStartedAt = Date.now();
aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream});
if (signal.aborted) throw new Error("Aborted");
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
await streamMessage.flush();
if (!stream) {
const request = {
model: config.mistralChatTarget.model,
messages,
tools: getMistralTools(),
documents: documents
} as unknown as Parameters<typeof mistralAi.chat.complete>[0];
const response = await mistralAi.chat.complete(request, {signal});
const msg = response.choices?.[0]?.message;
const text = typeof msg?.content === "string" ? msg.content : JSON.stringify(msg?.content ?? "");
streamMessage.append(text);
const calls = normalizeMistralToolCalls(mistralToolCalls(msg));
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: text.length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
messages.push({
role: "assistant",
content: text,
toolCalls: calls.map(call => ({
id: call.id,
function: {name: call.name, arguments: call.argumentsText},
})),
});
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
name: call.name,
toolCallId: call.id,
content: toolResults[index] ?? "",
});
}
continue;
}
const request = {
model: config.mistralChatTarget.model,
messages,
tools: getMistralTools(),
documents: documents
} as unknown as Parameters<typeof mistralAi.chat.stream>[0];
const streamResponse = await mistralAi.chat.stream(request, {signal});
aiLog("debug", "mistral.stream.open", {round});
let calls: ToolCallData[] = [];
const roundTextStart = streamMessage.getText().length;
const toolCallAccumulator = new StreamingToolCallAccumulator("mistral_stream", round);
for await (const event of streamResponse) {
if (signal.aborted) throw new Error("Aborted");
const choice = event.data?.choices?.[0];
const delta = choice?.delta;
const mistralDelta = delta as MistralDeltaLike;
streamMessage.append(contentFromMistralDelta(mistralDelta));
const rawDeltaCalls = mistralToolCalls(mistralDelta);
if (rawDeltaCalls.length) {
calls = toolCallAccumulator.add(rawDeltaCalls);
streamMessage.setStatus(Environment.getUseToolText(calls));
await streamMessage.flush();
}
}
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: streamMessage.getText().slice(roundTextStart).length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
const roundText = streamMessage.getText().slice(roundTextStart);
messages.push({
role: "assistant",
content: roundText,
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
});
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
name: call.name,
toolCallId: call.id,
content: toolResults[index] ?? "",
});
}
}
}
export class MistralProviderRunner {
static run = runMistral;
}
+404
View File
@@ -0,0 +1,404 @@
// Ollama provider runner extracted from unified-ai-runner.ts.
import {Message} from "typescript-telegram-bot-api";
import * as fs from "node:fs";
import path from "node:path";
import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment";
import {bot, notesDir} from "../index";
import {clamp, logError} from "../util/utils";
import {getOllamaTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {getModelCapabilities} from "./provider-model-runtime";
import {ChatMessage} from "./chat-messages-types";
import {ChatRequest, Tool} from "ollama";
import {ToolRuntimeContext} from "./tools/runtime";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {getCurrentDateTimeTool} from "./tools/datetime";
import {getMarketRatesTool} from "./tools/market-rates";
import {getWeatherTool} from "./tools/weather";
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
import {createOllamaClient} from "./ai-runtime-target";
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "./ai-logger";
import {DEFAULT_OLLAMA_CONTEXT_SIZE, MAX_OLLAMA_CONTEXT_SIZE, MAX_TOOL_ROUNDS, MIN_OLLAMA_CONTEXT_SIZE, RuntimeConfigSnapshot, Think, ToolCallData, ToolExecutionMemory, allToolSchemaNames, appendOllamaToolResults, dedupeToolCalls, executeToolBatch, normalizeOllamaToolCalls, roundStatus, safeJsonParseObject, isRecord, isOllamaModelActive, OllamaToolCallLike} from "./unified-ai-runner.shared";
import {latestUserTextFromOllamaMessages, looksLikeToolRankerJson, OllamaToolRanker} from "./unified-ai-runner.tool-ranker";
export async function runOllama(
msg: Message,
messages: ChatMessage[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
think: Think,
firstRoundStatus: string,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
contextSize?: number,
): Promise<void> {
const fromId = msg.from?.id;
const runnerStartedAt = Date.now();
const audioCount = messages.reduce((sum, m) => sum + (m.audioParts?.length || m.audios?.length || 0), 0);
const videoNoteCount = messages.reduce((sum, m) => sum + (m.videoNotes?.length ?? 0), 0);
const imageCount = messages.reduce((sum, m) => sum + (m.imageParts?.length || m.images?.length || 0), 0);
const target = (audioCount || videoNoteCount) ? config.ollamaAudioTarget :
imageCount ? config.ollamaVisionTarget :
think ? config.ollamaThinkingTarget : config.ollamaChatTarget;
const model = target.model;
aiLog("info", "ollama.run.start", {
stream,
think,
target: aiLogProviderTarget(target),
requestedContextSize: contextSize,
message: aiLogMessageIdentity(msg),
counts: {messages: messages.length, images: imageCount, audio: audioCount, videoNotes: videoNoteCount},
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
const ollama = createOllamaClient(target);
const modelInfo = await ollama.show({model});
const modelInfoMap = isRecord(modelInfo.model_info) ? modelInfo.model_info : {};
const contextKey = Object.keys(modelInfoMap).find(k => k.endsWith(".context_length"));
// @ts-ignore
const rawMaxContextLength = contextKey ? modelInfoMap[contextKey] : undefined;
const parsedMaxContextLength =
typeof rawMaxContextLength === "number"
? rawMaxContextLength
: typeof rawMaxContextLength === "string"
? Number(rawMaxContextLength)
: DEFAULT_OLLAMA_CONTEXT_SIZE;
const maxContextLength = Number.isFinite(parsedMaxContextLength)
? parsedMaxContextLength
: DEFAULT_OLLAMA_CONTEXT_SIZE;
const context = clamp(
contextSize === -1 ? MAX_OLLAMA_CONTEXT_SIZE : contextSize ?? DEFAULT_OLLAMA_CONTEXT_SIZE,
MIN_OLLAMA_CONTEXT_SIZE,
maxContextLength ?? DEFAULT_OLLAMA_CONTEXT_SIZE
);
aiLog("debug", "ollama.context.resolved", {model, contextKey, maxContextLength, context});
const modelsToLoad = [model];
try {
const activeModels = (await ollama.ps()).models.map(m => m.model);
const oldSet = new Set(activeModels);
const newSet = new Set(modelsToLoad);
const added = modelsToLoad.filter(m => !oldSet.has(m));
const removed = activeModels.filter(m => !newSet.has(m));
const diff = [...added, ...removed];
aiLog("debug", "ollama.models.active", {activeModels, requiredModels: modelsToLoad, added, removed});
if (diff.length) {
aiLog("info", "ollama.models.unload_extra", {keep: modelsToLoad, diff});
await unloadAllOllamaModels(ollama, modelsToLoad);
}
} catch (e) {
logError(e);
}
if (!(await isOllamaModelActive(ollama, target))) {
const loadStartedAt = Date.now();
aiLog("info", "ollama.model.load.start", {model, context});
const currentStatus = streamMessage.getStatus();
streamMessage.setStatus(Environment.getLoadingModelText(model));
await streamMessage.flush();
if (await loadOllamaModel(model, ollama, context)) {
aiLog("success", "ollama.model.load.done", {model, duration: aiLogDuration(loadStartedAt)});
streamMessage.setStatus(currentStatus ?? Environment.waitThinkText);
await streamMessage.flush();
}
} else {
aiLog("debug", "ollama.model.already_loaded", {model});
}
let interval: ReturnType<typeof setInterval> | null = null;
if (!stream) {
let typingInFlight = false;
const applyTyping = async () => {
if (typingInFlight) return;
typingInFlight = true;
try {
await enqueueTelegramApiCall(
() => bot.sendChatAction({chat_id: msg.chat.id, action: "typing"}),
{method: "sendChatAction", chatId: msg.chat.id, chatType: msg.chat.type}
).catch(logError);
} finally {
typingInFlight = false;
}
};
await applyTyping();
interval = setInterval(() => {
applyTyping().catch(logError);
}, 5000);
}
const toolMemory: ToolExecutionMemory = new Map();
try {
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const roundStartedAt = Date.now();
aiLog("debug", "ollama.round.start", {
round,
context,
messages: messages.length,
stream,
think: audioCount ? false : think,
});
const request: ChatRequest = {
model: model,
messages: messages,
think: audioCount ? false : think,
options: {
temperature: 0.6,
num_ctx: context,
}
};
let activeToolNames: string[] = [];
if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
const availableOllamaTools: Tool[] = fromId !== Environment.CREATOR_ID
? [getCurrentDateTimeTool, getMarketRatesTool, getWeatherTool]
: getOllamaTools() as Tool[];
aiLog("debug", "ollama.tools.available", {
round,
tools: allToolSchemaNames(availableOllamaTools),
rankerEnabled: !!config.ollamaToolRankerTarget,
});
const rankerSelection = await new OllamaToolRanker(config).selectTools({
userQuery: latestUserTextFromOllamaMessages(messages),
availableTools: availableOllamaTools,
round,
signal,
});
activeToolNames = rankerSelection.selectedNames;
if (rankerSelection.tools.length > 0) {
request.tools = rankerSelection.tools;
} else {
delete request.tools;
}
aiLog("debug", "ollama.tools.selected", {
round,
tools: activeToolNames,
count: activeToolNames.length,
usedRanker: rankerSelection.usedRanker,
missing: rankerSelection.missing,
});
}
if (!stream) {
const response = await ollama.chat({
...request,
stream: false
});
const message = response.message;
const rawContent = message?.content ?? "";
const nativeCalls = dedupeToolCalls(
normalizeOllamaToolCalls(
message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
),
);
const responseText = rawContent;
if (looksLikeToolRankerJson(responseText)) {
aiLog("error", "ollama.response.looks_like_tool_ranker_json", {
round,
preview: responseText.slice(0, 800),
target: aiLogProviderTarget(target),
});
throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up.");
}
streamMessage.append(responseText);
aiLog("debug", "ollama.response.received", {
round,
duration: aiLogDuration(roundStartedAt),
textChars: responseText.length,
nativeToolCallCount: nativeCalls.length,
});
if (!nativeCalls.length) {
aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)});
break;
}
const calls = nativeCalls;
aiLog("info", "ollama.tool_calls", {
round,
calls: calls.map(aiLogToolCall),
});
messages.push({
role: "assistant",
content: responseText,
tool_calls: calls.map(c => ({
function: {
name: c.name,
arguments: safeJsonParseObject(c.argumentsText),
},
})),
});
appendOllamaToolResults(
messages,
calls,
await executeToolBatch(calls, streamMessage, toolContext, toolMemory),
);
continue;
}
const response = await ollama.chat({
...request,
stream: true
});
aiLog("debug", "ollama.stream.open", {round});
const calls: ToolCallData[] = [];
const roundTextStart = streamMessage.getText().length;
const abortOllamaResponse = () => response.abort?.();
signal.addEventListener("abort", abortOllamaResponse, {once: true});
if (signal.aborted) abortOllamaResponse();
try {
for await (const chunk of response) {
const localToolCalls: ToolCallData[] = [];
localToolCalls.push(...normalizeOllamaToolCalls(
chunk.message.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
));
const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking);
const previousStatus = streamMessage.getStatus();
if (newStatus && newStatus !== Environment.waitThinkText) {
streamMessage.setStatus(newStatus);
} else {
streamMessage.clearStatus();
}
if (streamMessage.getStatus() !== previousStatus && previousStatus && newStatus !== Environment.waitThinkText) {
await streamMessage.flush();
}
if (signal.aborted) {
response.abort?.();
throw new Error("Aborted");
}
if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) {
streamMessage.append(chunk.message?.content ?? "");
}
calls.push(...normalizeOllamaToolCalls(
chunk.message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
));
if (chunk.done) {
aiLog("debug", "ollama.stream.done", {
round,
duration: aiLogDuration(roundStartedAt),
textChars: streamMessage.getText().slice(roundTextStart).length,
toolCallCount: calls.length,
});
await streamMessage.flush(streamMessage.regenerateKeyboard(), true);
}
}
} finally {
signal.removeEventListener("abort", abortOllamaResponse);
}
const streamedRoundText = streamMessage.getText().slice(roundTextStart);
if (!calls.length && looksLikeToolRankerJson(streamedRoundText)) {
streamMessage.replaceText(streamMessage.getText().slice(0, roundTextStart));
aiLog("error", "ollama.response.looks_like_tool_ranker_json", {
round,
preview: streamedRoundText.slice(0, 800),
target: aiLogProviderTarget(target),
});
throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up.");
}
if (!calls.length) {
aiLog("success", "ollama.run.done", {
round,
duration: aiLogDuration(runnerStartedAt),
});
break;
}
calls.splice(0, calls.length, ...dedupeToolCalls(calls));
aiLog("info", "ollama.tool_calls", {
round,
calls: calls.map(aiLogToolCall),
});
const roundText = streamMessage.getText().slice(roundTextStart);
messages.push({
role: "assistant",
content: roundText,
tool_calls: calls.map(c => ({
function: {
name: c.name,
arguments: safeJsonParseObject(c.argumentsText),
},
})),
});
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
for (const toolResult of toolResults) {
try {
const raw = JSON.parse(toolResult);
const res = GetNoteFileResultSchema.safeParse(raw);
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch {
// Not every tool result is JSON.
}
}
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
}).catch(logError);
}
appendOllamaToolResults(messages, calls, toolResults);
}
} finally {
if (interval) clearInterval(interval);
}
}
export class OllamaProviderRunner {
static run = runOllama;
}
+426
View File
@@ -0,0 +1,426 @@
// OpenAI and OpenAI-compatible provider runners extracted from unified-ai-runner.ts.
import {Message} from "typescript-telegram-bot-api";
import {Environment} from "../common/environment";
import {getOpenAITools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime";
import {OpenAIChatMessage} from "./openai-chat-message";
import type {ResponseCreateParamsNonStreaming, ResponseCreateParamsStreaming, ResponseInputItem, ResponseStreamEvent} from "openai/resources/responses/responses";
import type {ChatCompletionCreateParamsNonStreaming, ChatCompletionCreateParamsStreaming} from "openai/resources/chat/completions";
import {createGeminiOpenAiClient, createOpenAiClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "./ai-logger";
import {AsyncIterableStream, MAX_TOOL_ROUNDS, OPENAI_IMAGE_PARTIALS, OpenAiChatCompletionResponseLike, OpenAiChatToolCallLike, OpenAiCompatibleChatMessage, OpenAiCompatibleContentPart, OpenAiResponseLike, OpenAiResponseOutputItem, RuntimeConfigSnapshot, ToolCallData, StreamingToolCallAccumulator, collectOpenAiResponseFunctionCalls, collectOpenAiResponseImages, collectOpenAiResponseText, executeToolBatch, getOpenAIResponsesToolsWithImage, openAiResponseItemCallId, safeJsonParseObject, showOpenAiGeneratedImage, ToolExecutionMemory, isRecord, roundStatus, OpenAiChatCompletionStreamChunkLike} from "./unified-ai-runner.shared";
export async function runOpenAi(
messages: OpenAIChatMessage[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
firstRoundStatus: string,
sourceMessage: Message,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
): Promise<void> {
// TODO: 13.05.2026: remove
firstRoundStatus;
const runnerStartedAt = Date.now();
let responseInput: unknown[] = [...messages];
const openAi = createOpenAiClient(config.openAiChatTarget);
aiLog("info", "openai.run.start", {
stream,
target: aiLogProviderTarget(config.openAiChatTarget),
imageTarget: aiLogProviderTarget(config.openAiImageTarget),
inputMessages: messages.length,
sourceMessage: aiLogMessageIdentity(sourceMessage),
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
const toolMemory: ToolExecutionMemory = new Map();
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const roundStartedAt = Date.now();
aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream});
if (!stream) {
const request: ResponseCreateParamsNonStreaming = {
model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[],
// TODO: 13.05.2026, Danil Nikolaev: fix
tools: getOpenAIResponsesToolsWithImage(config) as any,
instructions: config.systemPrompt,
};
const response = await openAi.responses.create(request, {signal}) as unknown as OpenAiResponseLike;
const responseText = collectOpenAiResponseText(response);
streamMessage.append(responseText);
aiLog("debug", "openai.response.received", {
round,
duration: aiLogDuration(roundStartedAt),
textChars: responseText.length,
outputItems: response?.output?.length ?? 0,
});
const images = collectOpenAiResponseImages(response);
if (images.length) {
await showOpenAiGeneratedImage(
streamMessage,
sourceMessage,
images[images.length - 1],
`final_${round}`,
Environment.getImageGenDoneText(config.openAiImageTarget.model),
true,
);
}
const calls = collectOpenAiResponseFunctionCalls(response);
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
calls: calls.map(call => ({
id: call.callId,
name: call.name,
arguments: safeJsonParseObject(call.argumentsText)
})),
});
if (!calls.length) return;
const toolCalls = calls.map(call => ({
id: call.callId,
name: call.name,
argumentsText: call.argumentsText,
}));
const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory);
const toolOutputs = calls.map((call, index) => ({
type: "function_call_output" as const,
call_id: call.callId,
output: toolResults[index] ?? "",
}));
responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs];
continue;
}
let completedResponse: OpenAiResponseLike | null = null;
const request: ResponseCreateParamsStreaming = {
model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[],
stream: true,
// TODO: 13.05.2026, Danil Nikolaev: fix
tools: getOpenAIResponsesToolsWithImage(config) as any,
};
const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream<ResponseStreamEvent>;
aiLog("debug", "openai.stream.open", {round});
let localToolCalls: ToolCallData[] = [];
for await (const event of response) {
if (signal.aborted) throw new Error("Aborted");
switch (event.type) {
case "response.output_text.delta":
streamMessage.append(event.delta ?? "");
break;
case "response.image_generation_call.in_progress":
streamMessage.setStatus(Environment.startingImageGenText);
await streamMessage.flush();
break;
case "response.image_generation_call.generating":
streamMessage.setStatus(Environment.imageGenText);
await streamMessage.flush();
break;
case "response.image_generation_call.partial_image": {
const iteration = (event.partial_image_index ?? 0) + 1;
await showOpenAiGeneratedImage(
streamMessage,
sourceMessage,
event.partial_image_b64,
`partial_${round}_${iteration}`,
Environment.getPartialImageGenText(iteration, OPENAI_IMAGE_PARTIALS),
false,
);
break;
}
case "response.image_generation_call.completed":
streamMessage.setStatus(Environment.finalizingImageGenText);
await streamMessage.flush();
break;
case "response.output_item.added":
if (event.item.type === "function_call" && event.item.name) {
const item = event.item as OpenAiResponseOutputItem & { id?: string };
localToolCalls.push({
id: openAiResponseItemCallId(item),
name: item.name ?? "",
argumentsText: item.arguments ?? "{}",
});
aiLog("info", "openai.stream.tool_call.added", {
round,
toolCalls: localToolCalls.map(aiLogToolCall)
});
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
await streamMessage.flush();
}
break;
case "response.output_item.done":
if (event.item.type === "function_call" && event.item.name) {
const item = event.item as OpenAiResponseOutputItem & { id?: string };
const itemId = openAiResponseItemCallId(item);
const index = localToolCalls.findIndex(c => c.id === itemId);
if (index !== -1) {
localToolCalls.splice(index, 1);
if (localToolCalls.length === 0) {
streamMessage.clearStatus();
} else {
streamMessage.setStatus(Environment.getUseToolText(localToolCalls));
}
await streamMessage.flush();
}
}
break;
case "response.function_call_arguments.delta":
break;
case "response.function_call_arguments.done":
break;
case "response.completed":
completedResponse = event.response as unknown as OpenAiResponseLike;
break;
case "response.failed":
throw new Error(event.response?.error?.message ?? "OpenAI response failed");
case "error":
throw new Error(event.message ?? event?.message ?? "OpenAI stream error");
}
}
if (!completedResponse) throw new Error("OpenAI did not return the final response.completed event.");
aiLog("debug", "openai.stream.completed", {
round,
duration: aiLogDuration(roundStartedAt),
outputItems: completedResponse?.output?.length ?? 0,
});
const images = collectOpenAiResponseImages(completedResponse);
if (images.length) {
await showOpenAiGeneratedImage(
streamMessage,
sourceMessage,
images[images.length - 1],
`final_${round}`,
Environment.getImageGenDoneText(config.openAiImageTarget.model),
true,
);
}
const calls = collectOpenAiResponseFunctionCalls(completedResponse);
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
calls: calls.map(call => ({
id: call.callId,
name: call.name,
arguments: safeJsonParseObject(call.argumentsText)
})),
});
if (!calls.length) return;
const toolCalls = calls.map(call => ({
id: call.callId,
name: call.name,
argumentsText: call.argumentsText,
}));
const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory);
const toolOutputs = calls.map((call, index) => ({
type: "function_call_output",
call_id: call.callId,
output: toolResults[index] ?? "",
}));
responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs];
}
}
function openAiResponseContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.map(part => isRecord(part) ? part.text ?? part.content ?? part.refusal ?? "" : "").join("");
}
function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAiCompatibleChatMessage[] {
return messages.map((message): OpenAiCompatibleChatMessage => {
if (message.role === "system" || message.role === "assistant") {
return {
role: message.role,
content: openAiResponseContentToText(message.content),
};
}
const content = Array.isArray(message.content)
? message.content.map((part): OpenAiCompatibleContentPart => {
if (isRecord(part) && part.type === "input_image") {
return {
type: "image_url",
image_url: {url: String(part.image_url ?? "")},
};
}
return {
type: "text",
text: isRecord(part) && typeof part.text === "string" ? part.text : "",
};
})
: message.content;
return {role: "user", content};
});
}
function normalizeOpenAiChatToolCalls(toolCalls: OpenAiChatToolCallLike[] = []): ToolCallData[] {
return toolCalls.map((call, i) => ({
id: call.id || `openai_chat_${Date.now()}_${i}`,
name: call.function?.name || call.name || "",
argumentsText: typeof call.function?.arguments === "string"
? call.function.arguments
: JSON.stringify(call.function?.arguments ?? call.arguments ?? {}),
})).filter(call => call.name);
}
async function appendOpenAiChatToolResults(
messages: OpenAiCompatibleChatMessage[],
calls: ToolCallData[],
results: string[],
): Promise<void> {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
tool_call_id: call.id,
content: results[index] ?? "",
});
}
}
export async function runOpenAiCompatibleChat(
messages: OpenAIChatMessage[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
firstRoundStatus: string,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
): Promise<void> {
const runnerStartedAt = Date.now();
const geminiOpenAi = createGeminiOpenAiClient(config.geminiChatTarget);
const chatMessages = openAiResponseMessagesToChatCompletions(messages);
const toolMemory: ToolExecutionMemory = new Map();
aiLog("info", "openai_compatible.run.start", {
stream,
target: aiLogProviderTarget(config.geminiChatTarget),
inputMessages: messages.length,
chatMessages: chatMessages.length,
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const roundStartedAt = Date.now();
aiLog("debug", "openai_compatible.round.start", {round, messages: chatMessages.length, stream});
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
await streamMessage.flush();
if (!stream) {
const request: ChatCompletionCreateParamsNonStreaming = {
model: config.geminiChatTarget.model,
messages: chatMessages,
tools: getOpenAITools(),
temperature: 0.6,
};
const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as OpenAiChatCompletionResponseLike;
const message = response.choices?.[0]?.message;
streamMessage.append(message?.content ?? "");
const calls = normalizeOpenAiChatToolCalls(message?.tool_calls ?? []);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: message?.content?.length ?? 0,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
chatMessages.push({
role: "assistant",
content: message?.content ?? "",
tool_calls: calls.map(call => ({
id: call.id,
type: "function" as const,
function: {
name: call.name,
arguments: call.argumentsText,
},
})),
});
await appendOpenAiChatToolResults(chatMessages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
continue;
}
const request: ChatCompletionCreateParamsStreaming = {
model: config.geminiChatTarget.model,
messages: chatMessages,
tools: getOpenAITools(),
temperature: 0.6,
stream: true,
};
const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>;
aiLog("debug", "openai_compatible.stream.open", {round});
// const streamToolCalls: OpenAiChatToolCallLike[] = [];
const roundTextStart = streamMessage.getText().length;
const toolCallAccumulator = new StreamingToolCallAccumulator("openai_chat_stream", round);
let calls: ToolCallData[] = [];
for await (const chunk of response) {
if (signal.aborted) throw new Error("Aborted");
const delta = chunk.choices?.[0]?.delta;
streamMessage.append(delta?.content ?? "");
if (delta?.tool_calls?.length) {
calls = toolCallAccumulator.add(delta.tool_calls);
streamMessage.setStatus(Environment.getUseToolText(calls));
await streamMessage.flush();
}
}
// const calls = collectOpenAiChatStreamToolCalls(streamToolCalls);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: streamMessage.getText().slice(roundTextStart).length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
const roundText = streamMessage.getText().slice(roundTextStart);
chatMessages.push({
role: "assistant",
content: roundText,
tool_calls: calls.map(call => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.argumentsText,
},
})),
});
await appendOpenAiChatToolResults(chatMessages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
}
}
export class OpenAiProviderRunner {
static run = runOpenAi;
}
export class OpenAiCompatibleProviderRunner {
static run = runOpenAiCompatibleChat;
}
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
import {Tool} from "ollama";
import {AiRuntimeTarget, createOllamaClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget} from "./ai-logger";
import {allToolSchemaNames, isRecord, JsonObject, RuntimeConfigSnapshot, safeJsonParseObject, toolSchemaNames} from "./unified-ai-runner.shared";
type RankedToolStep = {
t: string | string[];
h?: string;
from?: string;
};
type RankedToolPlan = {
s?: RankedToolStep[];
m?: string;
};
export type ToolRankerSelection = {
tools: Tool[];
selectedNames: string[];
missing: string;
raw: string;
usedRanker: boolean;
};
export class OllamaToolRanker {
constructor(private readonly config: RuntimeConfigSnapshot) {}
async selectTools(args: {
userQuery: string;
availableTools: Tool[];
round: number;
signal: AbortSignal;
}): Promise<ToolRankerSelection> {
const {availableTools, round, signal, userQuery} = args;
const target = this.config.ollamaToolRankerTarget;
if (!availableTools.length) {
return {tools: [], selectedNames: [], missing: "", raw: "", usedRanker: false};
}
// Ranker disabled/unconfigured: keep old behavior and expose all allowed tools.
if (!target?.model) {
return {
tools: availableTools,
selectedNames: allToolSchemaNames(availableTools),
missing: "",
raw: "",
usedRanker: false,
};
}
const startedAt = Date.now();
const availableNames = new Set(allToolSchemaNames(availableTools));
const prompt = this.config.rankerToolPrompt?.trim() || DEFAULT_TOOL_RANKER_PROMPT;
const toolsForPrompt = availableTools.map(tool => ({
names: toolSchemaNames(tool),
schema: tool,
}));
aiLog("debug", "ollama.tool_ranker.start", {
round,
target: aiLogProviderTarget(target),
queryChars: userQuery.length,
availableTools: [...availableNames],
});
try {
const ollama = createOllamaClient(target as AiRuntimeTarget);
const response = await ollama.chat({
model: target.model,
messages: [
{role: "system", content: prompt},
{
role: "user",
content: JSON.stringify({
q: userQuery,
tools: toolsForPrompt,
}),
},
],
stream: false,
options: {
temperature: 0,
num_ctx: 8192,
},
});
if (signal.aborted) throw new Error("Aborted");
const raw = response.message?.content?.trim() ?? "";
const plan = parseToolRankerPlan(raw);
const selectedNames = normalizeToolRankerNames(plan, availableNames);
const selectedNameSet = new Set(selectedNames);
const tools = availableTools.filter(tool => toolSchemaNames(tool).some(name => selectedNameSet.has(name)));
const missing = typeof plan?.m === "string" ? plan.m.trim() : "";
aiLog("debug", "ollama.tool_ranker.done", {
round,
duration: aiLogDuration(startedAt),
selectedNames,
selectedCount: tools.length,
missing,
rawPreview: raw.slice(0, 800),
});
// Important: if the plan is valid and empty, use NO tools. Do not fall back to all tools.
return {tools, selectedNames, missing, raw, usedRanker: true};
} catch (error) {
if (String(error).includes("Aborted")) throw error;
aiLog("warn", "ollama.tool_ranker.failed.fallback_all_allowed", {
round,
target: aiLogProviderTarget(target),
duration: aiLogDuration(startedAt),
error,
});
// Ranker transport/model failure is different from "ranker returned empty plan".
// In that case, preserve availability rather than silently disabling tools.
return {
tools: availableTools,
selectedNames: allToolSchemaNames(availableTools),
missing: "",
raw: "",
usedRanker: false,
};
}
}
}
export function latestUserTextFromOllamaMessages(messages: readonly { role?: string; content?: unknown }[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message?.role !== "user") continue;
if (typeof message.content === "string") return message.content;
if (Array.isArray(message.content)) {
return message.content
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
.filter(Boolean)
.join("\n");
}
}
return "";
}
export function looksLikeToolRankerJson(text: string): boolean {
const parsed = safeJsonParseObject(extractJsonObjectText(text) ?? text);
return Array.isArray(parsed.s) && typeof parsed.m === "string";
}
function parseToolRankerPlan(raw: string): RankedToolPlan | undefined {
const jsonText = extractJsonObjectText(raw);
if (!jsonText) return undefined;
const parsed = safeJsonParseObject(jsonText) as JsonObject;
if (!Array.isArray(parsed.s)) return undefined;
return parsed as RankedToolPlan;
}
function normalizeToolRankerNames(plan: RankedToolPlan | undefined, availableNames: Set<string>): string[] {
if (!plan?.s?.length) return [];
const result: string[] = [];
for (const step of plan.s) {
const rawNames = Array.isArray(step.t) ? step.t : [step.t];
for (const rawName of rawNames) {
if (typeof rawName !== "string") continue;
const name = rawName.trim();
if (availableNames.has(name) && !result.includes(name)) {
result.push(name);
}
}
}
return result;
}
function extractJsonObjectText(raw: string): string | undefined {
const text = raw.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
const start = text.indexOf("{");
if (start === -1) return undefined;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "{") depth++;
if (ch === "}") depth--;
if (depth === 0) {
return text.slice(start, i + 1);
}
}
return undefined;
}
const DEFAULT_TOOL_RANKER_PROMPT = `You are a tool router. Return strict compact JSON only.
Schema: {"s":[{"t":"tool_name","h":"short input hint","from":"previous_tool.output_or_empty"}],"m":""}
Use tools only when they are needed. If no tool is needed, return {"s":[],"m":""}.
Never answer the user. Never explain. Never use markdown.`;
+128 -2067
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -96,8 +96,8 @@ export class AiCancel extends CallbackCommand {
{method: "editMessageText", chatId: message.chat.id, chatType: message.chat.type}
);
if (result && result !== true) {
await MessageStore.put({...result, text: cancelledText} as Message);
if (result) {
await MessageStore.put({...(result as object), text: cancelledText} as Message);
} else {
await MessageStore.put({
chatId: message.chat.id,
+8 -6
View File
@@ -21,8 +21,9 @@ export class Ae extends Command {
try {
let result = this.executeEvaluation(match);
await oldSendMessage(msg, result).catch(async () => await errorPlaceholder(msg));
} catch (e: any) {
const text = e.message.toString();
} catch (e: unknown) {
const error = e instanceof Error ? e : new Error(String(e));
const text = error.message.toString();
if (text.includes("is not defined")) {
await oldSendMessage(msg, Environment.variableNotDefinedText).catch(logError);
@@ -30,7 +31,7 @@ export class Ae extends Command {
}
logError(`${text}
* Stacktrace: ${e.stack}`);
* Stacktrace: ${error.stack}`);
await oldSendMessage(msg, text).catch(logError);
}
@@ -43,15 +44,16 @@ export class Ae extends Command {
e = ((typeof e == "string") ? e : JSON.stringify(e));
return e;
} catch (e: any) {
const text = e.message.toString();
} catch (e: unknown) {
const error = e instanceof Error ? e : new Error(String(e));
const text = error.message.toString();
if (text.includes("is not defined")) {
return Environment.evaluationVariableNotDefinedText;
}
logError(`${text}
* Stacktrace: ${e.stack}`);
* Stacktrace: ${error.stack}`);
return text;
}
+1 -1
View File
@@ -62,7 +62,7 @@ export class Distort extends Command {
}),
{method: "sendPhoto", chatId, chatType: msg.chat.type}
);
} catch (e: any) {
} catch (e: unknown) {
await oldReplyToMessage(
msg, Environment.getDistortFailedText(e)
).catch(logError);
+1 -1
View File
@@ -69,7 +69,7 @@ export class Qr extends Command {
}),
{method: "sendPhoto", chatId, chatType: msg.chat.type}
);
} catch (e: any) {
} catch (e: unknown) {
await replyToMessage({
message: msg,
text: Environment.getQrCodeFailedText(e)
+2 -2
View File
@@ -1636,8 +1636,8 @@ export class Environment {
private static getFileMtimeMs(filePath: string): number | undefined {
try {
return fs.statSync(filePath).mtimeMs;
} catch (e: any) {
if (e?.code === "ENOENT") {
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === "ENOENT") {
return undefined;
}
+2 -2
View File
@@ -23,8 +23,8 @@ function normalizeLanguageCode(value: string | undefined | null): string | undef
function readMtimeMs(filePath: string): number | undefined {
try {
return fs.statSync(filePath).mtimeMs;
} catch (e: any) {
if (e?.code === "ENOENT") return undefined;
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw e;
}
}
+1 -2
View File
@@ -1,7 +1,6 @@
export type OllamaRequest = {
uuid: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stream: any;
stream: unknown;
done: boolean;
fromId: number;
chatId: number;
+5 -4
View File
@@ -68,11 +68,12 @@ export class ShellCommandRunner {
}
return {stdout, stderr};
} catch (error: any) {
console.error("Error code:", error.code);
console.error("Stderr:", error.stderr);
} catch (error: unknown) {
const err = error as Partial<Error & {code: unknown; stdout: string; stderr: string}>;
console.error("Error code:", err.code);
console.error("Stderr:", err.stderr);
return {stdout: error.stdout ?? null, stderr: error.stderr ?? error.message};
return {stdout: err.stdout ?? null, stderr: err.stderr ?? err.message ?? String(error)};
}
}
+10 -11
View File
@@ -1,4 +1,5 @@
import * as si from "systeminformation";
import {redactLogValue} from "../ai/ai-logger";
import {Command} from "../base/command";
import {CallbackCommand} from "../base/callback-command";
import {
@@ -65,7 +66,7 @@ export const ignoreIfMarkupFailed = (e: Error | TelegramError) => {
};
export const logError = (e: Error | TelegramError | string | unknown) => {
console.error(e);
console.error(redactLogValue(e));
};
export const errorPlaceholder = async (msg: Message) => {
@@ -307,13 +308,13 @@ export async function editMessageText(options: EditOptions, retries = 1) {
}
);
return Promise.resolve(message);
} catch (e: any) {
} catch (e: unknown) {
logError(e);
if (isMarkupFailed(e)) {
if (isMarkupFailed(e as Error | TelegramError)) {
return Promise.resolve(true);
} else if (isTooManyRequests(e) && retries > 0) {
const retryAfter = Number(e.message.split("retry after ")[1]) || 30;
} else if (isTooManyRequests(e as Error | TelegramError) && retries > 0) {
const retryAfter = Number((e instanceof Error ? e.message : String(e)).split("retry after ")[1]) || 30;
await delay(retryAfter * 1000);
return editMessageText(options, retries - 1);
} else {
@@ -1836,9 +1837,10 @@ export function startIntervalEditor(params: {
try {
await params.editFn(next);
lastSent = next;
} catch (e: any) {
if ((e?.description ?? e?.message ?? "").includes("message is not modified")) return;
logError("edit failed: " + e);
} catch (e: unknown) {
const description = e instanceof Error ? e.message : String(e);
if (description.includes("message is not modified")) return;
logError("edit failed: " + description);
}
};
@@ -1896,7 +1898,6 @@ type RuntimeInfo =
| { runtime: "unknown"; version: string };
export function getRuntimeInfo(): RuntimeInfo {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const v = process.versions ?? {};
if (typeof v.bun === "string") {
@@ -1906,7 +1907,6 @@ export function getRuntimeInfo(): RuntimeInfo {
return {runtime: "node", version: v.node};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {runtime: "unknown", version: String(process.version ?? "")};
}
@@ -1957,7 +1957,6 @@ export async function imageToBase64(filePath: string, withMimeType: boolean = fa
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function ifTrue(exp?: string | number | boolean): boolean {
if (!exp) return false;