This commit is contained in:
2026-05-13 12:05:55 +03:00
parent c5b61ee3d8
commit 674c3cbd44
43 changed files with 382 additions and 639 deletions
+1 -283
View File
@@ -1,283 +1 @@
import {Message} from "typescript-telegram-bot-api"; export * from "../logging/ai-logger";
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,
};
}
+12 -2
View File
@@ -1,5 +1,8 @@
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider";
import {appLogger} from "../logging/logger";
const logger = appLogger.child("ai-provider-queue");
export type AiRequestQueueTarget = { export type AiRequestQueueTarget = {
provider: AiProvider; provider: AiProvider;
@@ -31,6 +34,7 @@ class AiProviderRequestQueue {
enqueue<T>(target: AiRequestQueueTarget, options: EnqueueOptions<T>): Promise<T> { enqueue<T>(target: AiRequestQueueTarget, options: EnqueueOptions<T>): Promise<T> {
if (options.signal?.aborted) { if (options.signal?.aborted) {
logger.debug("enqueue.rejected.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl});
return Promise.reject(new Error("Aborted")); return Promise.reject(new Error("Aborted"));
} }
@@ -53,12 +57,14 @@ class AiProviderRequestQueue {
const removed = this.removeWaitingEntry(entry); const removed = this.removeWaitingEntry(entry);
if (!removed) return; if (!removed) return;
logger.debug("entry.cancelled", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queueKey});
reject(new Error("Aborted")); reject(new Error("Aborted"));
this.schedule(target); this.schedule(target);
}; };
options.signal?.addEventListener("abort", entry.abortHandler, {once: true}); options.signal?.addEventListener("abort", entry.abortHandler, {once: true});
this.getOrCreateQueue(queueKey).push(entry); this.getOrCreateQueue(queueKey).push(entry);
logger.debug("enqueue.accepted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queued: this.getOrCreateQueue(queueKey).length, active: this.activeCount(queueKey)});
this.schedule(target); this.schedule(target);
}); });
} }
@@ -132,12 +138,14 @@ class AiProviderRequestQueue {
} }
if (entry.signal?.aborted) { if (entry.signal?.aborted) {
logger.debug("entry.skipped.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queueKey});
entry.reject(new Error("Aborted")); entry.reject(new Error("Aborted"));
continue; continue;
} }
entry.started = true; entry.started = true;
this.setActiveCount(queueKey, this.activeCount(queueKey) + 1); this.setActiveCount(queueKey, this.activeCount(queueKey) + 1);
logger.debug("entry.started", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queued: queue.length, active: this.activeCount(queueKey)});
void this.runEntry(entry); void this.runEntry(entry);
} }
@@ -150,7 +158,9 @@ class AiProviderRequestQueue {
private async runEntry(entry: QueueEntry): Promise<void> { private async runEntry(entry: QueueEntry): Promise<void> {
try { try {
entry.resolve(await entry.run()); entry.resolve(await entry.run());
logger.debug("entry.done", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl});
} catch (e) { } catch (e) {
logger.error("entry.failed", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl, error: e});
entry.reject(e); entry.reject(e);
} finally { } finally {
this.setActiveCount(entry.queueKey, this.activeCount(entry.queueKey) - 1); this.setActiveCount(entry.queueKey, this.activeCount(entry.queueKey) - 1);
@@ -168,10 +178,10 @@ class AiProviderRequestQueue {
})).then(results => { })).then(results => {
for (const result of results) { for (const result of results) {
if (result.status === "rejected") { if (result.status === "rejected") {
console.error(result.reason); logger.error("position_update.failed", {provider: target.provider, model: target.model, reason: result.reason});
} }
} }
}).catch(console.error); }).catch(error => logger.error("position_updates.failed", {provider: target.provider, model: target.model, error}));
} }
private deleteQueueIfIdle(queueKey: string, queue: QueueEntry[]): void { private deleteQueueIfIdle(queueKey: string, queue: QueueEntry[]): void {
+31 -5
View File
@@ -8,6 +8,7 @@ import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment
import {performFFmpeg} from "../util/ffmpeg"; import {performFFmpeg} from "../util/ffmpeg";
import ffmpeg from "fluent-ffmpeg"; import ffmpeg from "fluent-ffmpeg";
import {AsyncSemaphore, KeyedAsyncLock} from "../util/async-lock"; import {AsyncSemaphore, KeyedAsyncLock} from "../util/async-lock";
import {appLogger} from "../logging/logger";
export type AiDownloadedFile = { export type AiDownloadedFile = {
kind: StoredAttachmentKind; kind: StoredAttachmentKind;
@@ -20,6 +21,7 @@ export type AiDownloadedFile = {
const cachePathLocks = new KeyedAsyncLock(); const cachePathLocks = new KeyedAsyncLock();
const ffmpegSemaphore = new AsyncSemaphore(2); const ffmpegSemaphore = new AsyncSemaphore(2);
const logger = appLogger.child("attachments");
function safeFileName(value: string): string { function safeFileName(value: string): string {
return value.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_").slice(0, 180); return value.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_").slice(0, 180);
@@ -90,31 +92,48 @@ function cachePathFor(kind: StoredAttachmentKind, fileUniqueId: string | undefin
} }
async function downloadToCache(kind: StoredAttachmentKind, fileId: string, fileName: string, mimeType?: string, fileUniqueId?: string): Promise<StoredAttachment | null> { async function downloadToCache(kind: StoredAttachmentKind, fileId: string, fileName: string, mimeType?: string, fileUniqueId?: string): Promise<StoredAttachment | null> {
const startedAt = Date.now();
logger.debug("download.start", {kind, fileId, fileName, mimeType});
const file = await bot.getFile({file_id: fileId}); const file = await bot.getFile({file_id: fileId});
const finalFileName = fileNameWithExtension(fileName, mimeType, file.file_path); const finalFileName = fileNameWithExtension(fileName, mimeType, file.file_path);
const location = cachePathFor(kind, fileUniqueId, fileId, finalFileName); const location = cachePathFor(kind, fileUniqueId, fileId, finalFileName);
await cachePathLocks.runExclusive(location, async () => { await cachePathLocks.runExclusive(location, async () => {
if (fs.existsSync(location)) return; if (fs.existsSync(location)) {
logger.trace("download.cache_hit", {kind, location});
return;
}
const buffer = await downloadTelegramFile(file.file_path); const buffer = await downloadTelegramFile(file.file_path);
if (!buffer) return; if (!buffer) {
logger.warn("download.empty", {kind, fileId, telegramFilePath: file.file_path});
return;
}
const tempLocation = `${location}.${process.pid}.${Date.now()}.tmp`; const tempLocation = `${location}.${process.pid}.${Date.now()}.tmp`;
fs.mkdirSync(path.dirname(location), {recursive: true}); fs.mkdirSync(path.dirname(location), {recursive: true});
fs.writeFileSync(tempLocation, buffer); fs.writeFileSync(tempLocation, buffer);
fs.renameSync(tempLocation, location); fs.renameSync(tempLocation, location);
logger.debug("download.saved", {kind, location, bytes: buffer.length, duration: logger.duration(startedAt)});
}); });
return {kind, fileId, fileUniqueId, fileName: finalFileName, mimeType, cachePath: location}; return {kind, fileId, fileUniqueId, fileName: finalFileName, mimeType, cachePath: location};
} }
async function convertAudioToWav(input: string, output: string, noVideo = false): Promise<void> { async function convertAudioToWav(input: string, output: string, noVideo = false): Promise<void> {
const startedAt = Date.now();
logger.debug("audio.convert.start", {input, output, noVideo});
await cachePathLocks.runExclusive(output, async () => { await cachePathLocks.runExclusive(output, async () => {
if (fs.existsSync(output)) return; if (fs.existsSync(output)) {
logger.trace("audio.convert.cache_hit", {output});
return;
}
await ffmpegSemaphore.runExclusive(async () => { await ffmpegSemaphore.runExclusive(async () => {
if (fs.existsSync(output)) return; if (fs.existsSync(output)) {
logger.trace("audio.convert.cache_hit", {output});
return;
}
const tempOutput = `${output}.${process.pid}.${Date.now()}.tmp.wav`; const tempOutput = `${output}.${process.pid}.${Date.now()}.tmp.wav`;
try { try {
@@ -125,14 +144,16 @@ async function convertAudioToWav(input: string, output: string, noVideo = false)
.toFormat("wav") .toFormat("wav")
.save(tempOutput) .save(tempOutput)
.on("progress", (progress) => { .on("progress", (progress) => {
console.log("progress", progress); logger.trace("audio.convert.progress", {input, output, progress});
}); });
}); });
fs.renameSync(tempOutput, output); fs.renameSync(tempOutput, output);
logger.debug("audio.convert.done", {input, output, duration: logger.duration(startedAt)});
} catch (e) { } catch (e) {
if (fs.existsSync(tempOutput)) { if (fs.existsSync(tempOutput)) {
fs.rmSync(tempOutput, {force: true}); fs.rmSync(tempOutput, {force: true});
} }
logger.error("audio.convert.failed", {input, output, error: e});
throw e; throw e;
} }
}); });
@@ -140,7 +161,9 @@ async function convertAudioToWav(input: string, output: string, noVideo = false)
} }
export async function cacheMessageAttachments(msg: Message): Promise<StoredAttachment[]> { export async function cacheMessageAttachments(msg: Message): Promise<StoredAttachment[]> {
const startedAt = Date.now();
const result: StoredAttachment[] = []; const result: StoredAttachment[] = [];
logger.debug("message.cache.start", {chatId: msg.chat?.id, messageId: msg.message_id});
try { try {
if (msg.photo?.length) { if (msg.photo?.length) {
@@ -202,10 +225,12 @@ export async function cacheMessageAttachments(msg: Message): Promise<StoredAttac
logError(e); logError(e);
} }
logger.debug("message.cache.done", {chatId: msg.chat?.id, messageId: msg.message_id, attachments: result.length, duration: logger.duration(startedAt)});
return result; return result;
} }
export function attachmentsToDownloadedFiles(attachments: StoredAttachment[]): AiDownloadedFile[] { export function attachmentsToDownloadedFiles(attachments: StoredAttachment[]): AiDownloadedFile[] {
logger.trace("downloaded_files.build", {attachments: attachments.length});
return attachments return attachments
.filter(attachment => fs.existsSync(attachment.cachePath)) .filter(attachment => fs.existsSync(attachment.cachePath))
.map(attachment => ({ .map(attachment => ({
@@ -219,6 +244,7 @@ export function attachmentsToDownloadedFiles(attachments: StoredAttachment[]): A
} }
export function cleanupDownloads(files: AiDownloadedFile[]): void { export function cleanupDownloads(files: AiDownloadedFile[]): void {
logger.trace("downloaded_files.cleanup", {files: files.length});
// Files stay on disk in the message cache; drop in-memory buffers eagerly. // Files stay on disk in the message cache; drop in-memory buffers eagerly.
for (const file of files) { for (const file of files) {
file.buffer = Buffer.alloc(0); file.buffer = Buffer.alloc(0);
+6 -2
View File
@@ -1,4 +1,7 @@
import axios from "axios"; import axios from "axios";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("brave-search");
import {Environment} from "../../common/environment"; import {Environment} from "../../common/environment";
import {logError} from "../../util/utils"; import {logError} from "../../util/utils";
import {AiTool} from "../tool-types"; import {AiTool} from "../tool-types";
@@ -264,7 +267,8 @@ function normalizeBraveResultFilter(value: unknown): string {
} }
export async function webSearch(args?: Record<string, unknown>) { export async function webSearch(args?: Record<string, unknown>) {
console.log("braveSearch()"); const startedAt = Date.now();
logger.info("start", {args});
try { try {
const query = asNonEmptyString(args?.query); const query = asNonEmptyString(args?.query);
@@ -370,7 +374,7 @@ export async function webSearch(args?: Record<string, unknown>) {
response: data ?? null, response: data ?? null,
}; };
} finally { } finally {
console.log("END: braveSearch()"); logger.debug("done", {duration: logger.duration(startedAt)});
} }
} }
+7 -1
View File
@@ -4,6 +4,9 @@ import {readFile, writeFile} from "node:fs/promises";
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index"; import {NOTES_HEADER, notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils"; import {asNonEmptyString} from "./utils";
import fs from "node:fs"; import fs from "node:fs";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("create-note");
export type CreateNoteResult = export type CreateNoteResult =
| { success: true; filePath: string } | { success: true; filePath: string }
@@ -38,7 +41,8 @@ export const createNoteTool = {
export async function createNote( export async function createNote(
args?: Record<string, unknown> args?: Record<string, unknown>
): Promise<CreateNoteResult> { ): Promise<CreateNoteResult> {
console.log("CREATE_NOTE; ARGS: ", args); const startedAt = Date.now();
logger.debug("start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? ""; const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) { if (!fileName.trim().length) {
@@ -76,8 +80,10 @@ export async function createNote(
} }
await writeFile(notesRootFile, rootContent, "utf-8"); await writeFile(notesRootFile, rootContent, "utf-8");
logger.debug("done", {fileName, filePath: newFilePath, duration: logger.duration(startedAt)});
return {success: true, filePath: newFilePath}; return {success: true, filePath: newFilePath};
} catch (error) { } catch (error) {
logger.error("failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to process files: ${errorMessage}`}; return {success: false, error: `Failed to process files: ${errorMessage}`};
} }
+19 -4
View File
@@ -3,6 +3,9 @@ import path from "node:path";
import {readdir, readFile, unlink, writeFile} from "node:fs/promises"; import {readdir, readFile, unlink, writeFile} from "node:fs/promises";
import {notesDir, notesRootFile} from "../../index"; import {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils"; import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("notes");
export type NoteListItem = { export type NoteListItem = {
fileName: string; fileName: string;
@@ -58,7 +61,8 @@ export const getNoteContentTool = {
} satisfies AiTool; } satisfies AiTool;
export async function listNotes(): Promise<ListNotesResult> { export async function listNotes(): Promise<ListNotesResult> {
console.log("LIST_NOTES"); const startedAt = Date.now();
logger.debug("list.start");
try { try {
const entries = await readdir(notesDir, {withFileTypes: true}); const entries = await readdir(notesDir, {withFileTypes: true});
@@ -91,8 +95,10 @@ export async function listNotes(): Promise<ListNotesResult> {
notes.sort((a, b) => a.title.localeCompare(b.title)); notes.sort((a, b) => a.title.localeCompare(b.title));
logger.debug("list.done", {notes: notes.length, duration: logger.duration(startedAt)});
return {success: true, notes}; return {success: true, notes};
} catch (error) { } catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to list notes: ${errorMessage}`}; return {success: false, error: `Failed to list notes: ${errorMessage}`};
} }
@@ -101,7 +107,8 @@ export async function listNotes(): Promise<ListNotesResult> {
export async function getNoteContent( export async function getNoteContent(
args?: Record<string, unknown>, args?: Record<string, unknown>,
): Promise<GetNoteContentResult> { ): Promise<GetNoteContentResult> {
console.log("GET_NOTE_CONTENT; ARGS: ", args); const startedAt = Date.now();
logger.debug("get_content.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? ""; const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) { if (!fileName.trim().length) {
@@ -118,6 +125,7 @@ export async function getNoteContent(
const normalizedFileName = path.basename(noteFilePath); const normalizedFileName = path.basename(noteFilePath);
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath); const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
logger.debug("get_content.done", {fileName: normalizedFileName, relativePath, chars: content.length, duration: logger.duration(startedAt)});
return { return {
success: true, success: true,
fileName: normalizedFileName, fileName: normalizedFileName,
@@ -127,6 +135,7 @@ export async function getNoteContent(
content, content,
}; };
} catch (error) { } catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to read note: ${errorMessage}`}; return {success: false, error: `Failed to read note: ${errorMessage}`};
} }
@@ -219,7 +228,8 @@ export const deleteNoteTool = {
export async function updateNoteContent( export async function updateNoteContent(
args?: Record<string, unknown>, args?: Record<string, unknown>,
): Promise<UpdateNoteContentResult> { ): Promise<UpdateNoteContentResult> {
console.log("UPDATE_NOTE_CONTENT; ARGS: ", args); const startedAt = Date.now();
logger.debug("update_content.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? ""; const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) { if (!fileName.trim().length) {
@@ -239,9 +249,11 @@ export async function updateNoteContent(
try { try {
await readFile(noteFilePath, "utf-8"); await readFile(noteFilePath, "utf-8");
await writeFile(noteFilePath, content, "utf-8"); await writeFile(noteFilePath, content, "utf-8");
logger.debug("update_content.done", {fileName, filePath: noteFilePath, chars: content.length, duration: logger.duration(startedAt)});
return {success: true, filePath: noteFilePath}; return {success: true, filePath: noteFilePath};
} catch (error) { } catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to update note: ${errorMessage}`}; return {success: false, error: `Failed to update note: ${errorMessage}`};
} }
@@ -250,7 +262,8 @@ export async function updateNoteContent(
export async function deleteNote( export async function deleteNote(
args?: Record<string, unknown>, args?: Record<string, unknown>,
): Promise<DeleteNoteResult> { ): Promise<DeleteNoteResult> {
console.log("DELETE_NOTE; ARGS: ", args); const startedAt = Date.now();
logger.debug("delete.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? ""; const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) { if (!fileName.trim().length) {
@@ -265,9 +278,11 @@ export async function deleteNote(
try { try {
await unlink(noteFilePath); await unlink(noteFilePath);
await removeNoteLinkFromRoot(noteFilePath); await removeNoteLinkFromRoot(noteFilePath);
logger.debug("delete.done", {fileName, filePath: noteFilePath, duration: logger.duration(startedAt)});
return {success: true, filePath: noteFilePath}; return {success: true, filePath: noteFilePath};
} catch (error) { } catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to delete note: ${errorMessage}`}; return {success: false, error: `Failed to delete note: ${errorMessage}`};
} }
+7 -1
View File
@@ -1,5 +1,8 @@
import {AiTool} from "../tool-types"; import {AiTool} from "../tool-types";
import axios from "axios"; import axios from "axios";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("market-rates");
export const getMarketRatesTool = { export const getMarketRatesTool = {
type: "function", type: "function",
@@ -59,11 +62,14 @@ export const marketRatesToolPrompt = [
].join("\n"); ].join("\n");
export async function getMarketRates(): Promise<unknown | undefined> { export async function getMarketRates(): Promise<unknown | undefined> {
const startedAt = Date.now();
try { try {
logger.info("start");
const response = await axios.get("https://apid.r00t.top/api/v2/currency/rates"); const response = await axios.get("https://apid.r00t.top/api/v2/currency/rates");
logger.debug("done", {duration: logger.duration(startedAt), status: response.status});
return response.data; return response.data;
} catch (e: unknown) { } catch (e: unknown) {
console.error("GET_MARKET_RATES", e); logger.error("failed", {duration: logger.duration(startedAt), error: e});
return undefined; return undefined;
} }
} }
+16 -12
View File
@@ -5,6 +5,9 @@ import path from "node:path";
import {AiTool} from "../tool-types"; import {AiTool} from "../tool-types";
import {Environment} from "../../common/environment"; import {Environment} from "../../common/environment";
import {randomUUID} from "node:crypto"; import {randomUUID} from "node:crypto";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("python-interpreter");
export const PYTHON_INTERPRETER_TOOL_NAME = "python_interpreter"; export const PYTHON_INTERPRETER_TOOL_NAME = "python_interpreter";
@@ -203,17 +206,17 @@ export async function runPythonInterpreter(
}; };
} }
console.time("python.syntax"); const syntaxStartedAt = Date.now();
const syntax = await validatePythonSyntax(args.code, options); const syntax = await validatePythonSyntax(args.code, options);
console.timeEnd("python.syntax"); logger.debug("syntax.done", {duration: logger.duration(syntaxStartedAt), ok: syntax.ok});
if (!syntax.ok) { if (!syntax.ok) {
return syntax; return syntax;
} }
console.time("python.execution"); const executionStartedAt = Date.now();
const result = await executePythonCode(args, options); const result = await executePythonCode(args, options);
console.timeEnd("python.execution"); logger.debug("execution.done", {duration: logger.duration(executionStartedAt), ok: result.ok, phase: result.phase});
return result; return result;
} }
@@ -293,7 +296,8 @@ async function executePythonCode(
args: PythonInterpreterArgs, args: PythonInterpreterArgs,
options: PythonInterpreterOptions = {}, options: PythonInterpreterOptions = {},
): Promise<PythonToolResult> { ): Promise<PythonToolResult> {
console.log("EXECUTE_PYTHON_CODE", "ARGS: ", JSON.stringify(args), "; OPTIONS: ", JSON.stringify(options)); const startedAt = Date.now();
logger.info("execute.start", {args, options});
const pythonBinary = const pythonBinary =
options.pythonBinary ?? process.env.PYTHON_INTERPRETER_BINARY ?? "C:\\Users\\meloda\\Desktop\\AI_BOT\\.venv\\Scripts\\python.exe"; options.pythonBinary ?? process.env.PYTHON_INTERPRETER_BINARY ?? "C:\\Users\\meloda\\Desktop\\AI_BOT\\.venv\\Scripts\\python.exe";
@@ -329,7 +333,7 @@ async function executePythonCode(
mode: 0o600, mode: 0o600,
}); });
console.log("EXECUTE_PYTHON_CODE", "SCRIPT FILE WRITTEN", new Date()); logger.debug("script.written", {tempDir, userScriptPath, runnerPath, duration: logger.duration(startedAt)});
const result = await runProcess({ const result = await runProcess({
command: pythonBinary, command: pythonBinary,
@@ -346,10 +350,10 @@ async function executePythonCode(
}, },
}); });
console.log("EXECUTE_PYTHON_CODE", "RESULT ACHIEVED", new Date()); logger.debug("process.done", {duration: logger.duration(startedAt), exitCode: result.exitCode, timedOut: result.timedOut, outputTruncated: result.outputTruncated});
if (result.timedOut) { if (result.timedOut) {
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR TIMED OUT", new Date()); logger.warn("process.timeout", {duration: logger.duration(startedAt)});
return { return {
ok: false, ok: false,
phase: "execution", phase: "execution",
@@ -365,7 +369,7 @@ async function executePythonCode(
} }
if (result.outputTruncated) { if (result.outputTruncated) {
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR TRUNCATED", new Date()); logger.warn("process.output_truncated", {duration: logger.duration(startedAt), stdoutChars: result.stdout.length, stderrChars: result.stderr.length});
return { return {
ok: false, ok: false,
@@ -382,7 +386,7 @@ async function executePythonCode(
} }
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR EXIT CODE", new Date(), "\n", JSON.stringify(result, null, 2)); logger.warn("process.non_zero_exit", {duration: logger.duration(startedAt), result});
return { return {
ok: false, ok: false,
@@ -398,7 +402,7 @@ async function executePythonCode(
}; };
} }
console.log("EXECUTE_PYTHON_CODE", "RESULT NORMAL", new Date()); logger.debug("process.ok", {duration: logger.duration(startedAt)});
const { const {
artifacts, artifacts,
@@ -420,7 +424,7 @@ async function executePythonCode(
skippedArtifacts, skippedArtifacts,
}; };
} catch (error) { } catch (error) {
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR", new Date()); logger.error("execute.failed", {duration: logger.duration(startedAt), error});
return { return {
ok: false, ok: false,
phase: "internal", phase: "internal",
+10 -2
View File
@@ -1,6 +1,9 @@
import {getToolHandlers} from "./registry"; import {getToolHandlers} from "./registry";
import {normalizeToolArguments} from "./utils"; import {normalizeToolArguments} from "./utils";
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator"; import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("runtime");
export type ToolRuntimeContext = { export type ToolRuntimeContext = {
pythonInputFiles?: PythonInterpreterInputFile[]; pythonInputFiles?: PythonInterpreterInputFile[];
@@ -16,7 +19,9 @@ export async function executeToolCall(
args?: unknown, args?: unknown,
context: ToolRuntimeContext = {}, context: ToolRuntimeContext = {},
): Promise<string> { ): Promise<string> {
const startedAt = Date.now();
const handler = getToolHandlers()[name]; const handler = getToolHandlers()[name];
logger.info("execute.start", {name, args});
if (!handler) { if (!handler) {
return stringifyToolResult({ return stringifyToolResult({
@@ -35,14 +40,17 @@ export async function executeToolCall(
}); });
const s = stringifyToolResult(result); const s = stringifyToolResult(result);
console.log("PYTHON_INTERPRETER_STRING_RESULT", s); logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
return s; return s;
} }
const result = await handler(normalizeToolArguments(args)); const result = await handler(normalizeToolArguments(args));
return stringifyToolResult(result); const s = stringifyToolResult(result);
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
return s;
} catch (error) { } catch (error) {
logger.error("execute.failed", {name, duration: logger.duration(startedAt), error});
return stringifyToolResult({ return stringifyToolResult({
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}); });
+6 -1
View File
@@ -3,6 +3,9 @@ import path from "node:path";
import {readdir, readFile} from "node:fs/promises"; import {readdir, readFile} from "node:fs/promises";
import {notesDir, notesRootFile} from "../../index"; import {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils"; import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("search-notes");
export type SearchNoteMatchedField = "file_name" | "title" | "content"; export type SearchNoteMatchedField = "file_name" | "title" | "content";
@@ -51,7 +54,8 @@ export const searchNotesTool = {
export async function searchNotes( export async function searchNotes(
args?: Record<string, unknown>, args?: Record<string, unknown>,
): Promise<SearchNotesResult> { ): Promise<SearchNotesResult> {
console.log("SEARCH_NOTES; ARGS: ", args); const startedAt = Date.now();
logger.debug("start", {args});
const query = asNonEmptyString(args?.query) ?? ""; const query = asNonEmptyString(args?.query) ?? "";
if (!query.trim().length) { if (!query.trim().length) {
@@ -127,6 +131,7 @@ export async function searchNotes(
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
.slice(0, limit); .slice(0, limit);
logger.debug("done", {query, limit, results: results.length, duration: logger.duration(startedAt)});
return {success: true, results}; return {success: true, results};
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
+5 -2
View File
@@ -5,6 +5,9 @@ import {notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils"; import {asNonEmptyString} from "./utils";
import {buildSafeNoteFilePath} from "./list-notes"; import {buildSafeNoteFilePath} from "./list-notes";
import z from "zod"; import z from "zod";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("get-note-file");
export type NoteFileAttachment = { export type NoteFileAttachment = {
type: "local_file"; type: "local_file";
@@ -64,7 +67,7 @@ export const getNoteFileTool = {
export async function getNoteFile( export async function getNoteFile(
args?: Record<string, unknown>, args?: Record<string, unknown>,
): Promise<GetNoteFileResult> { ): Promise<GetNoteFileResult> {
console.log("GET_NOTE_FILE; ARGS: ", args); logger.debug("start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? ""; const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) { if (!fileName.trim().length) {
@@ -100,7 +103,7 @@ export async function getNoteFile(
}, },
}; };
console.log("GET_NOTE_FILE; RESULT: ", result); logger.debug("done", {fileName: result.attachment.fileName, relativePath: result.attachment.relativePath, sizeBytes: result.attachment.sizeBytes});
return result; return result;
} catch (error) { } catch (error) {
+8 -3
View File
@@ -1,5 +1,8 @@
import {Ollama} from "ollama"; import {Ollama} from "ollama";
import {z} from "zod"; import {z} from "zod";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("utils");
export function asNonEmptyString(value: unknown): string | undefined { export function asNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 return typeof value === "string" && value.trim().length > 0
@@ -79,14 +82,15 @@ export async function unloadAllOllamaModels(ollama: Ollama, exceptFor?: string[]
); );
await Promise.all(unloadPromises); await Promise.all(unloadPromises);
console.log("All models have been requested to unload" + (exceptFor?.length ? ` except for [${exceptFor?.join(", ")}].` : ".")); logger.info("ollama.unload_all.done", {count: modelsToUnload.length, exceptFor});
} catch (error) { } catch (error) {
console.error("Error unloading models:", error); logger.error("ollama.unload_all.failed", {exceptFor, error});
} }
} }
export async function loadOllamaModel(model: string, ollama: Ollama, contextLength: number): Promise<boolean> { export async function loadOllamaModel(model: string, ollama: Ollama, contextLength: number): Promise<boolean> {
try { try {
logger.info("ollama.load.start", {model, contextLength});
await ollama.generate({ await ollama.generate({
model: model, model: model,
stream: false, stream: false,
@@ -95,9 +99,10 @@ export async function loadOllamaModel(model: string, ollama: Ollama, contextLeng
num_ctx: contextLength num_ctx: contextLength
} }
}); });
logger.info("ollama.load.done", {model, contextLength});
return true; return true;
} catch (e: unknown) { } catch (e: unknown) {
console.error("Error loading Ollama model:", model); logger.error("ollama.load.failed", {model, contextLength, error: e});
return false; return false;
} }
} }
+9 -4
View File
@@ -1,4 +1,7 @@
import axios from "axios"; import axios from "axios";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("weather");
import {Environment} from "../../common/environment"; import {Environment} from "../../common/environment";
import {logError} from "../../util/utils"; import {logError} from "../../util/utils";
import {AiTool} from "../tool-types"; import {AiTool} from "../tool-types";
@@ -43,7 +46,8 @@ export const weatherToolPrompt = [
].join("\n"); ].join("\n");
export async function getWeather(args?: Record<string, unknown>): Promise<Record<string, unknown> | null> { export async function getWeather(args?: Record<string, unknown>): Promise<Record<string, unknown> | null> {
console.log("getWeather()"); const startedAt = Date.now();
logger.info("start", {args});
try { try {
const city = asNonEmptyString(args?.city); const city = asNonEmptyString(args?.city);
const lang = asNonEmptyString(args?.lang); const lang = asNonEmptyString(args?.lang);
@@ -61,7 +65,7 @@ export async function getWeather(args?: Record<string, unknown>): Promise<Record
appid: apiKey, appid: apiKey,
}, },
})).data[0]; })).data[0];
console.log("GEOCODE_RESPONSE", geocodeResponse); logger.debug("geocode.done", {city, country: geocodeResponse?.country, hasResult: !!geocodeResponse, geocodeResponse});
if (!geocodeResponse) { if (!geocodeResponse) {
return { return {
ok: false, ok: false,
@@ -83,7 +87,7 @@ export async function getWeather(args?: Record<string, unknown>): Promise<Record
...(lang ? {lang} : {}), ...(lang ? {lang} : {}),
}, },
})).data; })).data;
console.log("RESPONSE: getWeather(lang=" + lang + "): ", response); logger.debug("weather_api.done", {city, country: geocodeResponse.country, lang, units: "metric", hasResponse: !!response});
const main = response.main; const main = response.main;
const sys = response.sys; const sys = response.sys;
@@ -138,9 +142,10 @@ export async function getWeather(args?: Record<string, unknown>): Promise<Record
}, },
}; };
} catch (e: unknown) { } catch (e: unknown) {
logger.error("failed", {duration: logger.duration(startedAt), error: e});
logError(e); logError(e);
return null; return null;
} finally { } finally {
console.log("END: getWeather()"); logger.debug("done", {duration: logger.duration(startedAt)});
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime"; import {ToolRuntimeContext} from "./tools/runtime";
import {GeminiMessage} from "./gemini-chat-message"; import {GeminiMessage} from "./gemini-chat-message";
import {createGoogleGenAiClient} from "./ai-runtime-target"; import {createGoogleGenAiClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "./ai-logger"; import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {AsyncIterableStream, GeminiFunctionCallLike, GeminiResponseLike, MAX_TOOL_ROUNDS, RuntimeConfigSnapshot, ToolCallData, ToolExecutionMemory, executeToolBatch, roundStatus, safeJsonParseObject, GeminiGenerationRequest} from "./unified-ai-runner.shared"; import {AsyncIterableStream, GeminiFunctionCallLike, GeminiResponseLike, MAX_TOOL_ROUNDS, RuntimeConfigSnapshot, ToolCallData, ToolExecutionMemory, executeToolBatch, roundStatus, safeJsonParseObject, GeminiGenerationRequest} from "./unified-ai-runner.shared";
+1 -1
View File
@@ -5,7 +5,7 @@ import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime"; import {ToolRuntimeContext} from "./tools/runtime";
import {MistralChatMessage} from "./mistral-chat-message"; import {MistralChatMessage} from "./mistral-chat-message";
import {createMistralClient} from "./ai-runtime-target"; import {createMistralClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "./ai-logger"; import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {MAX_TOOL_ROUNDS, MistralDeltaLike, MistralDocumentReference, RuntimeConfigSnapshot, StreamingToolCallAccumulator, ToolCallData, ToolExecutionMemory, contentFromMistralDelta, executeToolBatch, mistralToolCalls, normalizeMistralToolCalls, roundStatus} from "./unified-ai-runner.shared"; import {MAX_TOOL_ROUNDS, MistralDeltaLike, MistralDocumentReference, RuntimeConfigSnapshot, StreamingToolCallAccumulator, ToolCallData, ToolExecutionMemory, contentFromMistralDelta, executeToolBatch, mistralToolCalls, normalizeMistralToolCalls, roundStatus} from "./unified-ai-runner.shared";
+1 -1
View File
@@ -19,7 +19,7 @@ import {getWeatherTool} from "./tools/weather";
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils"; import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
import {createOllamaClient} from "./ai-runtime-target"; import {createOllamaClient} from "./ai-runtime-target";
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file"; import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "./ai-logger"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/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 {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"; import {latestUserTextFromOllamaMessages, looksLikeToolRankerJson, OllamaToolRanker} from "./unified-ai-runner.tool-ranker";
+3 -5
View File
@@ -8,7 +8,7 @@ import {OpenAIChatMessage} from "./openai-chat-message";
import type {ResponseCreateParamsNonStreaming, ResponseCreateParamsStreaming, ResponseInputItem, ResponseStreamEvent} from "openai/resources/responses/responses"; import type {ResponseCreateParamsNonStreaming, ResponseCreateParamsStreaming, ResponseInputItem, ResponseStreamEvent} from "openai/resources/responses/responses";
import type {ChatCompletionCreateParamsNonStreaming, ChatCompletionCreateParamsStreaming} from "openai/resources/chat/completions"; import type {ChatCompletionCreateParamsNonStreaming, ChatCompletionCreateParamsStreaming} from "openai/resources/chat/completions";
import {createGeminiOpenAiClient, createOpenAiClient} from "./ai-runtime-target"; import {createGeminiOpenAiClient, createOpenAiClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "./ai-logger"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/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"; 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";
@@ -47,8 +47,7 @@ export async function runOpenAi(
const request: ResponseCreateParamsNonStreaming = { const request: ResponseCreateParamsNonStreaming = {
model: config.openAiChatTarget.model, model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[], input: responseInput as ResponseInputItem[],
// TODO: 13.05.2026, Danil Nikolaev: fix tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsNonStreaming["tools"],
tools: getOpenAIResponsesToolsWithImage(config) as any,
instructions: config.systemPrompt, instructions: config.systemPrompt,
}; };
const response = await openAi.responses.create(request, {signal}) as unknown as OpenAiResponseLike; const response = await openAi.responses.create(request, {signal}) as unknown as OpenAiResponseLike;
@@ -105,8 +104,7 @@ export async function runOpenAi(
model: config.openAiChatTarget.model, model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[], input: responseInput as ResponseInputItem[],
stream: true, stream: true,
// TODO: 13.05.2026, Danil Nikolaev: fix tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsStreaming["tools"],
tools: getOpenAIResponsesToolsWithImage(config) as any,
}; };
const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream<ResponseStreamEvent>; const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream<ResponseStreamEvent>;
+6 -8
View File
@@ -35,7 +35,7 @@ import {OllamaChatMessage} from "./ollama-chat-message";
import {GeminiMessage} from "./gemini-chat-message"; import {GeminiMessage} from "./gemini-chat-message";
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer"; import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
import {AiRuntimeTarget, createMistralClient, getGeminiApiMode, resolveAiRuntimeTarget} from "./ai-runtime-target"; import {AiRuntimeTarget, createMistralClient, getGeminiApiMode, resolveAiRuntimeTarget} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "./ai-logger"; import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
export type {Message} from "typescript-telegram-bot-api"; export type {Message} from "typescript-telegram-bot-api";
export type {AiRuntimeTarget} from "./ai-runtime-target"; export type {AiRuntimeTarget} from "./ai-runtime-target";
@@ -443,7 +443,6 @@ export function resolveAiRequestQueueTarget(
export function roundStatus(round: number, firstRoundStatus: string, content?: string, toolCalls?: ToolCallData[], thinking?: boolean): string | null { export function roundStatus(round: number, firstRoundStatus: string, content?: string, toolCalls?: ToolCallData[], thinking?: boolean): string | null {
if (content?.length && !toolCalls?.length && !thinking) { if (content?.length && !toolCalls?.length && !thinking) {
// console.log("ROUND_STATUS", "null");
return null; return null;
} }
@@ -452,7 +451,6 @@ export function roundStatus(round: number, firstRoundStatus: string, content?: s
: round === 0 ? firstRoundStatus : round === 0 ? firstRoundStatus
: Environment.waitThinkText; : Environment.waitThinkText;
// console.log("ROUND_STATUS", status);
return status; return status;
} }
@@ -1085,11 +1083,11 @@ export function appendTranscriptToChatMessages(
return partType === "input_text" || partType === "input_image"; return partType === "input_text" || partType === "input_image";
}); });
lastUser.content.push( if (usesOpenAiResponsesParts) {
(usesOpenAiResponsesParts (lastUser.content as ResponseInputMessageContentList).push({type: "input_text", text});
? {type: "input_text", text} } else {
: {type: "text", text}) as any, (lastUser.content as OpenAiCompatibleContentPart[]).push({type: "text", text});
); }
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import {Tool} from "ollama"; import {Tool} from "ollama";
import {AiRuntimeTarget, createOllamaClient} from "./ai-runtime-target"; import {AiRuntimeTarget, createOllamaClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget} from "./ai-logger"; import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger";
import {allToolSchemaNames, isRecord, JsonObject, RuntimeConfigSnapshot, safeJsonParseObject, toolSchemaNames} from "./unified-ai-runner.shared"; import {allToolSchemaNames, isRecord, JsonObject, RuntimeConfigSnapshot, safeJsonParseObject, toolSchemaNames} from "./unified-ai-runner.shared";
type RankedToolStep = { type RankedToolStep = {
+1 -1
View File
@@ -16,7 +16,7 @@ import {OllamaChatMessage} from "./ollama-chat-message";
import {GeminiMessage} from "./gemini-chat-message"; import {GeminiMessage} from "./gemini-chat-message";
import {buildAiRegenerateCallbackData} from "./regenerate-callback"; import {buildAiRegenerateCallbackData} from "./regenerate-callback";
import {createOllamaClient, getGeminiApiMode} from "./ai-runtime-target"; import {createOllamaClient, getGeminiApiMode} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "./ai-logger"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "../logging/ai-logger";
import {runOpenAi, runOpenAiCompatibleChat} from "./unified-ai-runner.openai"; import {runOpenAi, runOpenAiCompatibleChat} from "./unified-ai-runner.openai";
import {runOllama} from "./unified-ai-runner.ollama"; import {runOllama} from "./unified-ai-runner.ollama";
+4 -1
View File
@@ -3,6 +3,9 @@ import {Message} from "typescript-telegram-bot-api";
import {logError, oldReplyToMessage, randomValue} from "../util/utils"; import {logError, oldReplyToMessage, randomValue} from "../util/utils";
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer"; import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {appLogger} from "../logging/logger";
const logger = appLogger.child("command:choice");
export class Choice extends Command { export class Choice extends Command {
command = "choice"; command = "choice";
@@ -12,7 +15,7 @@ export class Choice extends Command {
description = Environment.commandDescriptions.choice; description = Environment.commandDescriptions.choice;
async execute(msg: Message, match?: RegExpExecArray): Promise<void> { async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
console.log("match", match); logger.debug("execute", {chatId: msg.chat?.id, messageId: msg.message_id, match});
const payload = match?.[3] || ""; const payload = match?.[3] || "";
+9 -11
View File
@@ -1,15 +1,13 @@
import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {formatRuntimeModelInfo} from "../ai/provider-model-runtime";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderGetModelCommand} from "./provider-model-command";
export class GeminiGetModel extends Command { export class GeminiGetModel extends ProviderGetModelCommand {
title = Environment.commandTitles.geminiGetModel; constructor() {
description = Environment.commandDescriptions.geminiGetModel; super({
provider: AiProvider.GEMINI,
async execute(msg: Message): Promise<void> { title: Environment.commandTitles.geminiGetModel,
await replyToMessage({message: msg, text: await formatRuntimeModelInfo(AiProvider.GEMINI)}).catch(logError); description: Environment.commandDescriptions.geminiGetModel,
});
} }
} }
+9 -24
View File
@@ -1,28 +1,13 @@
import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {escapeHtml, logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {listProviderModels} from "../ai/provider-model-runtime";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderListModelsCommand} from "./provider-model-command";
export class GeminiListModels extends Command { export class GeminiListModels extends ProviderListModelsCommand {
title = Environment.commandTitles.geminiListModels; constructor() {
description = Environment.commandDescriptions.geminiListModels; super({
provider: AiProvider.GEMINI,
requirements = Requirements.Build(Requirement.BOT_CREATOR); title: Environment.commandTitles.geminiListModels,
description: Environment.commandDescriptions.geminiListModels,
async execute(msg: Message): Promise<void> { });
try {
const models = (await listProviderModels(AiProvider.GEMINI)).sort((a, b) => a.localeCompare(b));
const modelsString = escapeHtml(models.join("\n").substring(0, 4000));
const text = Environment.modelListHeaderText + "<blockquote expandable>" + modelsString + "</blockquote>";
await replyToMessage({message: msg, text, parse_mode: "HTML"});
} catch (e) {
logError(e);
await replyToMessage({message: msg, text: Environment.modelListLoadFailedText}).catch(logError);
}
} }
} }
+9 -25
View File
@@ -1,29 +1,13 @@
import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {getRuntimeModel, setRuntimeModel, formatRuntimeModelInfo} from "../ai/provider-model-runtime";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderSetModelCommand} from "./provider-model-command";
export class GeminiSetModel extends Command { export class GeminiSetModel extends ProviderSetModelCommand {
argsMode = "required" as const; constructor() {
super({
title = Environment.commandTitles.geminiSetModel; provider: AiProvider.GEMINI,
description = Environment.commandDescriptions.geminiSetModel; title: Environment.commandTitles.geminiSetModel,
description: Environment.commandDescriptions.geminiSetModel,
requirements = Requirements.Build(Requirement.BOT_CREATOR); });
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
const newModel = match?.[3]?.trim();
if (newModel) setRuntimeModel(AiProvider.GEMINI, newModel);
const model = getRuntimeModel(AiProvider.GEMINI);
const text = newModel
? Environment.getSelectedModelWithInfoText(model, await formatRuntimeModelInfo(AiProvider.GEMINI))
: Environment.getModelIsNotSetCurrentText(model);
await replyToMessage({message: msg, text}).catch(logError);
} }
} }
+9 -11
View File
@@ -1,15 +1,13 @@
import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {formatRuntimeModelInfo} from "../ai/provider-model-runtime";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderGetModelCommand} from "./provider-model-command";
export class MistralGetModel extends Command { export class MistralGetModel extends ProviderGetModelCommand {
title = Environment.commandTitles.mistralGetModel; constructor() {
description = Environment.commandDescriptions.mistralGetModel; super({
provider: AiProvider.MISTRAL,
async execute(msg: Message): Promise<void> { title: Environment.commandTitles.mistralGetModel,
await replyToMessage({message: msg, text: await formatRuntimeModelInfo(AiProvider.MISTRAL)}).catch(logError); description: Environment.commandDescriptions.mistralGetModel,
});
} }
} }
+9 -24
View File
@@ -1,28 +1,13 @@
import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {escapeHtml, logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {listProviderModels} from "../ai/provider-model-runtime";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderListModelsCommand} from "./provider-model-command";
export class MistralListModels extends Command { export class MistralListModels extends ProviderListModelsCommand {
title = Environment.commandTitles.mistralListModels; constructor() {
description = Environment.commandDescriptions.mistralListModels; super({
provider: AiProvider.MISTRAL,
requirements = Requirements.Build(Requirement.BOT_CREATOR); title: Environment.commandTitles.mistralListModels,
description: Environment.commandDescriptions.mistralListModels,
async execute(msg: Message): Promise<void> { });
try {
const models = (await listProviderModels(AiProvider.MISTRAL)).sort((a, b) => a.localeCompare(b));
const modelsString = escapeHtml(models.join("\n").substring(0, 4000));
const text = Environment.modelListHeaderText + "<blockquote expandable>" + modelsString + "</blockquote>";
await replyToMessage({message: msg, text, parse_mode: "HTML"});
} catch (e) {
logError(e);
await replyToMessage({message: msg, text: Environment.modelListLoadFailedText}).catch(logError);
}
} }
} }
+9 -25
View File
@@ -1,29 +1,13 @@
import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {getRuntimeModel, setRuntimeModel, formatRuntimeModelInfo} from "../ai/provider-model-runtime";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderSetModelCommand} from "./provider-model-command";
export class MistralSetModel extends Command { export class MistralSetModel extends ProviderSetModelCommand {
argsMode = "required" as const; constructor() {
super({
title = Environment.commandTitles.mistralSetModel; provider: AiProvider.MISTRAL,
description = Environment.commandDescriptions.mistralSetModel; title: Environment.commandTitles.mistralSetModel,
description: Environment.commandDescriptions.mistralSetModel,
requirements = Requirements.Build(Requirement.BOT_CREATOR); });
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
const newModel = match?.[3]?.trim();
if (newModel) setRuntimeModel(AiProvider.MISTRAL, newModel);
const model = getRuntimeModel(AiProvider.MISTRAL);
const text = newModel
? Environment.getSelectedModelWithInfoText(model, await formatRuntimeModelInfo(AiProvider.MISTRAL))
: Environment.getModelIsNotSetCurrentText(model);
await replyToMessage({message: msg, text}).catch(logError);
} }
} }
+9 -11
View File
@@ -1,15 +1,13 @@
import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {formatRuntimeModelInfo} from "../ai/provider-model-runtime";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderGetModelCommand} from "./provider-model-command";
export class OllamaGetModel extends Command { export class OllamaGetModel extends ProviderGetModelCommand {
title = Environment.commandTitles.ollamaGetModel; constructor() {
description = Environment.commandDescriptions.ollamaGetModel; super({
provider: AiProvider.OLLAMA,
async execute(msg: Message): Promise<void> { title: Environment.commandTitles.ollamaGetModel,
await replyToMessage({message: msg, text: await formatRuntimeModelInfo(AiProvider.OLLAMA)}).catch(logError); description: Environment.commandDescriptions.ollamaGetModel,
});
} }
} }
+8 -30
View File
@@ -1,35 +1,13 @@
import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {escapeHtml, logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider";
import {listProviderModels} from "../ai/provider-model-runtime"; import {ProviderListModelsCommand} from "./provider-model-command";
import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target";
export class OllamaListModels extends Command { export class OllamaListModels extends ProviderListModelsCommand {
title = Environment.commandTitles.ollamaListModels; constructor() {
description = Environment.commandDescriptions.ollamaListModels; super({
provider: AiProvider.OLLAMA,
requirements = Requirements.Build(Requirement.BOT_CREATOR); title: Environment.commandTitles.ollamaListModels,
description: Environment.commandDescriptions.ollamaListModels,
async execute(msg: Message): Promise<void> { });
try {
const target = resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat");
const models = (await listProviderModels(AiProvider.OLLAMA)).sort((a, b) => a.localeCompare(b));
const modelsString = escapeHtml(models.join("\n").substring(0, 4000));
const loadedModels = ((await createOllamaClient(target).ps())?.models ?? [])
.map(model => model.model || model.name)
.filter((model): model is string => !!model);
const text =
Environment.getLoadedModelsText(loadedModels) + "\n\n" +
Environment.modelListHeaderText + "<blockquote expandable>" + modelsString + "</blockquote>";
await replyToMessage({message: msg, text, parse_mode: "HTML"});
} catch (e) {
logError(e);
await replyToMessage({message: msg, text: Environment.modelListLoadFailedText}).catch(logError);
}
} }
} }
+9 -25
View File
@@ -1,29 +1,13 @@
import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {getRuntimeModel, setRuntimeModel, formatRuntimeModelInfo} from "../ai/provider-model-runtime";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderSetModelCommand} from "./provider-model-command";
export class OllamaSetModel extends Command { export class OllamaSetModel extends ProviderSetModelCommand {
argsMode = "required" as const; constructor() {
super({
title = Environment.commandTitles.ollamaSetModel; provider: AiProvider.OLLAMA,
description = Environment.commandDescriptions.ollamaSetModel; title: Environment.commandTitles.ollamaSetModel,
description: Environment.commandDescriptions.ollamaSetModel,
requirements = Requirements.Build(Requirement.BOT_CREATOR); });
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
const newModel = match?.[3]?.trim();
if (newModel) setRuntimeModel(AiProvider.OLLAMA, newModel);
const model = getRuntimeModel(AiProvider.OLLAMA);
const text = newModel
? Environment.getSelectedModelWithInfoText(model, await formatRuntimeModelInfo(AiProvider.OLLAMA))
: Environment.getModelIsNotSetCurrentText(model);
await replyToMessage({message: msg, text}).catch(logError);
} }
} }
+9 -11
View File
@@ -1,15 +1,13 @@
import {Command} from "../base/command";
import {Message} from "typescript-telegram-bot-api";
import {logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {formatRuntimeModelInfo} from "../ai/provider-model-runtime";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderGetModelCommand} from "./provider-model-command";
export class OpenAIGetModel extends Command { export class OpenAIGetModel extends ProviderGetModelCommand {
title = Environment.commandTitles.openAiGetModel; constructor() {
description = Environment.commandDescriptions.openAiGetModel; super({
provider: AiProvider.OPENAI,
async execute(msg: Message): Promise<void> { title: Environment.commandTitles.openAiGetModel,
await replyToMessage({message: msg, text: await formatRuntimeModelInfo(AiProvider.OPENAI)}).catch(logError); description: Environment.commandDescriptions.openAiGetModel,
});
} }
} }
+9 -24
View File
@@ -1,28 +1,13 @@
import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {escapeHtml, logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {listProviderModels} from "../ai/provider-model-runtime";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderListModelsCommand} from "./provider-model-command";
export class OpenAIListModels extends Command { export class OpenAIListModels extends ProviderListModelsCommand {
title = Environment.commandTitles.openAiListModels; constructor() {
description = Environment.commandDescriptions.openAiListModels; super({
provider: AiProvider.OPENAI,
requirements = Requirements.Build(Requirement.BOT_CREATOR); title: Environment.commandTitles.openAiListModels,
description: Environment.commandDescriptions.openAiListModels,
async execute(msg: Message): Promise<void> { });
try {
const models = (await listProviderModels(AiProvider.OPENAI)).sort((a, b) => a.localeCompare(b));
const modelsString = escapeHtml(models.join("\n").substring(0, 4000));
const text = Environment.modelListHeaderText + "<blockquote expandable>" + modelsString + "</blockquote>";
await replyToMessage({message: msg, text, parse_mode: "HTML"});
} catch (e) {
logError(e);
await replyToMessage({message: msg, text: Environment.modelListLoadFailedText}).catch(logError);
}
} }
} }
+9 -25
View File
@@ -1,29 +1,13 @@
import {Command} from "../base/command";
import {Requirements} from "../base/requirements";
import {Requirement} from "../base/requirement";
import {Message} from "typescript-telegram-bot-api";
import {logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {getRuntimeModel, setRuntimeModel, formatRuntimeModelInfo} from "../ai/provider-model-runtime";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {ProviderSetModelCommand} from "./provider-model-command";
export class OpenAISetModel extends Command { export class OpenAISetModel extends ProviderSetModelCommand {
argsMode = "required" as const; constructor() {
super({
title = Environment.commandTitles.openAiSetModel; provider: AiProvider.OPENAI,
description = Environment.commandDescriptions.openAiSetModel; title: Environment.commandTitles.openAiSetModel,
description: Environment.commandDescriptions.openAiSetModel,
requirements = Requirements.Build(Requirement.BOT_CREATOR); });
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
const newModel = match?.[3]?.trim();
if (newModel) setRuntimeModel(AiProvider.OPENAI, newModel);
const model = getRuntimeModel(AiProvider.OPENAI);
const text = newModel
? Environment.getSelectedModelWithInfoText(model, await formatRuntimeModelInfo(AiProvider.OPENAI))
: Environment.getModelIsNotSetCurrentText(model);
await replyToMessage({message: msg, text}).catch(logError);
} }
} }
+11 -5
View File
@@ -21,6 +21,9 @@ import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {AsyncSemaphore} from "../util/async-lock"; import {AsyncSemaphore} from "../util/async-lock";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {getLruMapValue, setLruMapValue} from "../util/lru-map"; import {getLruMapValue, setLruMapValue} from "../util/lru-map";
import {appLogger} from "../logging/logger";
const logger = appLogger.child("command:quote");
try { try {
GlobalFonts.registerFromPath("./assets/Inter_18pt-Thin.ttf", "InterThin"); GlobalFonts.registerFromPath("./assets/Inter_18pt-Thin.ttf", "InterThin");
@@ -54,6 +57,8 @@ export class Quote extends Command {
if (!reply) return; if (!reply) return;
try { try {
const startedAt = Date.now();
logger.debug("execute.start", {chatId, messageId: msg.message_id, replyMessageId: reply.message_id});
const quoteRaw = (msg.quote?.text ?? reply.text ?? reply.caption ?? "").trim(); const quoteRaw = (msg.quote?.text ?? reply.text ?? reply.caption ?? "").trim();
if (quoteRaw.length === 0) { if (quoteRaw.length === 0) {
await replyToMessage({message: msg, text: Environment.quoteMissingTextText}).catch(logError); await replyToMessage({message: msg, text: Environment.quoteMissingTextText}).catch(logError);
@@ -74,7 +79,8 @@ export class Quote extends Command {
}, },
}), }),
{method: "sendPhoto", chatId, chatType: msg.chat.type} {method: "sendPhoto", chatId, chatType: msg.chat.type}
).catch(logError); );
logger.debug("execute.done", {chatId, messageId: msg.message_id, bytes: png.length, duration: logger.duration(startedAt)});
} catch (e) { } catch (e) {
logError(e); logError(e);
await replyToMessage({message: msg, text: Environment.quoteBuildFailedText}).catch(logError); await replyToMessage({message: msg, text: Environment.quoteBuildFailedText}).catch(logError);
@@ -146,14 +152,14 @@ async function loadCustomEmoji(customEmojiId: string): Promise<CanvasImage | nul
}); });
if (!stickerSet || stickerSet.length === 0) { if (!stickerSet || stickerSet.length === 0) {
console.warn(`Custom emoji ${customEmojiId} not found`); logger.warn("custom_emoji.not_found", {customEmojiId});
return null; return null;
} }
const sticker = stickerSet[0]; const sticker = stickerSet[0];
if (sticker.is_animated || sticker.is_video) { if (sticker.is_animated || sticker.is_video) {
console.warn(`Animated/video custom emoji ${customEmojiId} not supported`); logger.warn("custom_emoji.unsupported", {customEmojiId});
return loadEmoji(sticker.emoji); return loadEmoji(sticker.emoji);
} }
@@ -171,7 +177,7 @@ async function loadCustomEmoji(customEmojiId: string): Promise<CanvasImage | nul
setLruMapValue(customEmojiCache, customEmojiId, img, CUSTOM_EMOJI_CACHE_MAX_ENTRIES); setLruMapValue(customEmojiCache, customEmojiId, img, CUSTOM_EMOJI_CACHE_MAX_ENTRIES);
return img; return img;
} catch (e) { } catch (e) {
console.warn(`Failed to load custom emoji ${customEmojiId}:`, e); logger.warn("custom_emoji.load_failed", {customEmojiId, error: e});
return null; return null;
} }
} }
@@ -521,7 +527,7 @@ async function drawLine(ctx: SKRSContext2D, line: Segment[], x: number, baseline
ctx.drawImage(<Image>img, cx, y, emojiSize, emojiSize); ctx.drawImage(<Image>img, cx, y, emojiSize, emojiSize);
} }
} catch (e) { } catch (e) {
console.warn("Failed to draw custom emoji:", e); logger.warn("custom_emoji.draw_failed", {error: e});
try { try {
const img = await loadEmoji("😥"); const img = await loadEmoji("😥");
+2 -1
View File
@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import {parse as parseDotEnv} from "dotenv"; import {parse as parseDotEnv} from "dotenv";
import {z} from "zod"; import {z} from "zod";
import {appLogger} from "../logging/logger";
import {saveData} from "../db/database"; import {saveData} from "../db/database";
import {Answers} from "../model/answers"; import {Answers} from "../model/answers";
@@ -1851,7 +1852,7 @@ export class Environment {
Environment.lastRankerToolPromptMtimeMs = rankerToolPromptMtimeMs; Environment.lastRankerToolPromptMtimeMs = rankerToolPromptMtimeMs;
} }
} catch (e) { } catch (e) {
console.error("Failed to reload runtime environment config", e); appLogger.child("environment").error("runtime_reload.failed", {error: e});
} }
} }
+6 -2
View File
@@ -1,6 +1,9 @@
import {AsyncLocalStorage} from "node:async_hooks"; import {AsyncLocalStorage} from "node:async_hooks";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import {appLogger} from "../logging/logger";
const logger = appLogger.child("localization");
export const DEFAULT_LOCALE = "en"; export const DEFAULT_LOCALE = "en";
export const DEFAULT_LANGUAGE_CHOICE = "default"; export const DEFAULT_LANGUAGE_CHOICE = "default";
@@ -235,7 +238,7 @@ export class Localization {
try { try {
bundles.set(locale, JSON.parse(fs.readFileSync(filePath, "utf8")) as LocalizationBundle); bundles.set(locale, JSON.parse(fs.readFileSync(filePath, "utf8")) as LocalizationBundle);
} catch (e) { } catch (e) {
console.error(`Failed to load localization file ${filePath}`, e); logger.error("file_load.failed", {filePath, locale, error: e});
const previous = Localization.bundles.get(locale); const previous = Localization.bundles.get(locale);
if (previous) bundles.set(locale, previous); if (previous) bundles.set(locale, previous);
} }
@@ -244,8 +247,9 @@ export class Localization {
Localization.bundles = bundles; Localization.bundles = bundles;
Localization.fileMtimeMs = mtimes; Localization.fileMtimeMs = mtimes;
Localization.fileSignature = signature; Localization.fileSignature = signature;
logger.debug("reload.done", {force, locales: [...bundles.keys()]});
} catch (e) { } catch (e) {
console.error("Failed to reload localization files", e); logger.error("reload.failed", {error: e});
} }
} }
} }
+6 -5
View File
@@ -4,12 +4,13 @@ import {StoredMessage} from "../model/stored-message";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {inArray} from "drizzle-orm/sql/expressions/conditions"; import {inArray} from "drizzle-orm/sql/expressions/conditions";
import {Dao} from "../base/dao"; import {Dao} from "../base/dao";
import {appLogger} from "../logging/logger";
import {buildExcludedSet} from "../util/utils"; import {buildExcludedSet} from "../util/utils";
import {StoredAttachment} from "../model/stored-attachment"; import {StoredAttachment} from "../model/stored-attachment";
export class MessageDao extends Dao<StoredMessage> { export class MessageDao extends Dao<StoredMessage> {
private tag: string = "MessageDao"; private readonly logger = appLogger.child("dao:messages");
override async getAll(): Promise<StoredMessage[]> { override async getAll(): Promise<StoredMessage[]> {
await DatabaseManager.ready; await DatabaseManager.ready;
@@ -19,7 +20,7 @@ export class MessageDao extends Dao<StoredMessage> {
const now = Date.now(); const now = Date.now();
const diff = now - then; const diff = now - then;
console.log(`${this.tag}: getAll()`, `took ${diff}ms; size: ${messages.length}`); this.logger.trace("get_all", {dao: "messages", duration: `${diff}ms`, size: messages.length});
return this.mapFrom(messages); return this.mapFrom(messages);
} }
@@ -40,7 +41,7 @@ export class MessageDao extends Dao<StoredMessage> {
const now = Date.now(); const now = Date.now();
const diff = now - then; const diff = now - then;
console.log(`${this.tag}: getById(${params.chatId}, ${params.id})`, `took ${diff}ms; size: ${messages.length}`); this.logger.trace("get_by_id", {dao: "messages", chatId: params.chatId, id: params.id, duration: `${diff}ms`, size: messages.length});
const m = messages[0]; const m = messages[0];
if (!m) return null; if (!m) return null;
@@ -63,7 +64,7 @@ export class MessageDao extends Dao<StoredMessage> {
const now = Date.now(); const now = Date.now();
const diff = now - then; const diff = now - then;
console.log(`${this.tag}: getByIds(${params.chatId}, ${params.ids})`, `took ${diff}ms; size: ${messages.length}`); this.logger.trace("get_by_ids", {dao: "messages", chatId: params.chatId, ids: params.ids, duration: `${diff}ms`, size: messages.length});
return this.mapFrom(messages); return this.mapFrom(messages);
} }
@@ -83,7 +84,7 @@ export class MessageDao extends Dao<StoredMessage> {
const now = Date.now(); const now = Date.now();
const diff = now - then; const diff = now - then;
console.log(`${this.tag}: insert(size: ${values.length})`, `took ${diff}ms'; inserted: ${r.rowsAffected}`); this.logger.debug("insert", {dao: "messages", duration: `${diff}ms`, size: values.length, rowsAffected: r.rowsAffected});
return true; return true;
} }
+6 -5
View File
@@ -1,5 +1,6 @@
import {StoredUser} from "../model/stored-user"; import {StoredUser} from "../model/stored-user";
import {Dao} from "../base/dao"; import {Dao} from "../base/dao";
import {appLogger} from "../logging/logger";
import {DatabaseManager} from "./database-manager"; import {DatabaseManager} from "./database-manager";
import {UserInsert, usersTable} from "./schema"; import {UserInsert, usersTable} from "./schema";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
@@ -9,7 +10,7 @@ import {boolToInt, buildExcludedSet} from "../util/utils";
export class UserDao extends Dao<StoredUser> { export class UserDao extends Dao<StoredUser> {
private tag: string = "UserDao"; private readonly logger = appLogger.child("dao:users");
override async getAll(): Promise<StoredUser[]> { override async getAll(): Promise<StoredUser[]> {
await DatabaseManager.ready; await DatabaseManager.ready;
@@ -19,7 +20,7 @@ export class UserDao extends Dao<StoredUser> {
const now = Date.now(); const now = Date.now();
const diff = now - then; const diff = now - then;
console.log(`${this.tag}: getAll()`, `took ${diff}ms; size: ${users.length}`); this.logger.trace("get_all", {dao: "users", duration: `${diff}ms`, size: users.length});
return this.mapFrom(users); return this.mapFrom(users);
} }
@@ -37,7 +38,7 @@ export class UserDao extends Dao<StoredUser> {
const now = Date.now(); const now = Date.now();
const diff = now - then; const diff = now - then;
console.log(`${this.tag}: getById(${params.id})`, `took ${diff}ms; size: ${users.length}`); this.logger.trace("get_by_id", {dao: "users", id: params.id, duration: `${diff}ms`, size: users.length});
const u = users[0]; const u = users[0];
if (!u) return null; if (!u) return null;
@@ -57,7 +58,7 @@ export class UserDao extends Dao<StoredUser> {
const now = Date.now(); const now = Date.now();
const diff = now - then; const diff = now - then;
console.log(`${this.tag}: getByIds(${params.ids})`, `took ${diff}ms; size: ${users.length}`); this.logger.trace("get_by_ids", {dao: "users", ids: params.ids, duration: `${diff}ms`, size: users.length});
return this.mapFrom(users); return this.mapFrom(users);
} }
@@ -78,7 +79,7 @@ export class UserDao extends Dao<StoredUser> {
const now = Date.now(); const now = Date.now();
const diff = now - then; const diff = now - then;
console.log(`${this.tag}: insert(size: ${rows.length})`, `took ${diff}ms; inserted: ${r.rowsAffected}`); this.logger.debug("insert", {dao: "users", duration: `${diff}ms`, size: rows.length, rowsAffected: r.rowsAffected});
return true; return true;
} }
+14 -10
View File
@@ -1,4 +1,5 @@
import "dotenv/config"; import "dotenv/config";
import {appLogger} from "./logging/logger";
import {Environment} from "./common/environment"; import {Environment} from "./common/environment";
import {BotCommand, TelegramBot, User} from "typescript-telegram-bot-api"; import {BotCommand, TelegramBot, User} from "typescript-telegram-bot-api";
import {Command} from "./base/command"; import {Command} from "./base/command";
@@ -194,13 +195,15 @@ export const NOTES_HEADER = "## Notes\n";
export const notesDir = path.join(Environment.DATA_PATH, "notes"); export const notesDir = path.join(Environment.DATA_PATH, "notes");
export const notesRootFile = path.join(notesDir, "index.md"); export const notesRootFile = path.join(notesDir, "index.md");
const logger = appLogger.child("main");
let isShuttingDown = false; let isShuttingDown = false;
async function shutdown(signal: NodeJS.Signals) { async function shutdown(signal: NodeJS.Signals) {
if (isShuttingDown) return; if (isShuttingDown) return;
isShuttingDown = true; isShuttingDown = true;
console.log(`Received ${signal}. Stopping bot polling...`); logger.warn("shutdown.signal", {signal});
try { try {
await bot.stopPolling(); await bot.stopPolling();
@@ -214,27 +217,30 @@ async function shutdown(signal: NodeJS.Signals) {
async function main() { async function main() {
const start = Date.now(); const start = Date.now();
console.log([ logger.info("startup.config", {
`TEST_ENVIRONMENT: ${Environment.TEST_ENVIRONMENT}`, testEnvironment: Environment.TEST_ENVIRONMENT,
`IS_DOCKER: ${Environment.IS_DOCKER}`, isDocker: Environment.IS_DOCKER,
`DATA_PATH: ${Environment.DATA_PATH}`, dataPath: Environment.DATA_PATH,
`DB_PATH: ${Environment.DB_PATH}` dbPath: Environment.DB_PATH,
].join("\n")); });
const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir]; const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir];
dirsToCheck.forEach(dir => { dirsToCheck.forEach(dir => {
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, {recursive: true}); fs.mkdirSync(dir, {recursive: true});
logger.debug("startup.dir_created", {dir});
} }
}); });
const notesRootFilePath = path.join(notesDir, "index.md"); const notesRootFilePath = path.join(notesDir, "index.md");
if (!fs.existsSync(notesRootFilePath)) { if (!fs.existsSync(notesRootFilePath)) {
fs.writeFileSync(notesRootFilePath, "\n" + NOTES_HEADER); fs.writeFileSync(notesRootFilePath, "\n" + NOTES_HEADER);
logger.debug("startup.notes_root_created", {notesRootFilePath});
} }
if (!(fs.readFileSync(notesRootFilePath).toString().includes(NOTES_HEADER))) { if (!(fs.readFileSync(notesRootFilePath).toString().includes(NOTES_HEADER))) {
fs.appendFileSync(notesRootFilePath, "\n" + NOTES_HEADER); fs.appendFileSync(notesRootFilePath, "\n" + NOTES_HEADER);
logger.debug("startup.notes_header_added", {notesRootFilePath});
} }
// TODO: 13/05/2026, Danil Nikolaev: maybe add clean cache option (or just save summarizations) // TODO: 13/05/2026, Danil Nikolaev: maybe add clean cache option (or just save summarizations)
@@ -245,12 +251,10 @@ async function main() {
// midnight.setDate(now.getDate() + 1); // midnight.setDate(now.getDate() + 1);
// const diff = midnight.getTime() - now.getTime(); // const diff = midnight.getTime() - now.getTime();
// console.log("Clearing up cache will be started in " + diff + "ms");
// clearUpFolderFromOldFiles(cacheDir); // clearUpFolderFromOldFiles(cacheDir);
// delay(diff).then(() => { // delay(diff).then(() => {
// setInterval(() => { // setInterval(() => {
// console.log("Started clearing up cache");
// clearUpFolderFromOldFiles(cacheDir); // clearUpFolderFromOldFiles(cacheDir);
// }, 1000 * 60 * 60 * 24); // }, 1000 * 60 * 60 * 24);
// }); // });
@@ -278,7 +282,7 @@ async function main() {
const end = Date.now(); const end = Date.now();
const diff = Math.abs(end - start); const diff = Math.abs(end - start);
console.log(`Bot started in ${diff}ms!`); logger.success("startup.ready", {duration: `${diff}ms`, commands: cmds.length, botId: botUser.id, botUsername: botUser.username});
} catch (error) { } catch (error) {
logError(error); logError(error);
} }
+10 -2
View File
@@ -1,6 +1,9 @@
import {logError} from "./utils"; import {logError} from "./utils";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import {appLogger} from "../logging/logger";
const logger = appLogger.child("files");
export function clearUpFolderFromOldFiles(folder: string, recursive = true) { export function clearUpFolderFromOldFiles(folder: string, recursive = true) {
fs.readdir(folder, (err, files) => { fs.readdir(folder, (err, files) => {
@@ -33,11 +36,16 @@ export function clearUpFolderFromOldFiles(folder: string, recursive = true) {
} }
}); });
console.log("filenamesToDelete", filenamesToDelete); logger.debug("cleanup.candidates", {folder, recursive, count: filenamesToDelete.length, filenamesToDelete});
if (filenamesToDelete.length) { if (filenamesToDelete.length) {
filenamesToDelete.forEach((filename) => { filenamesToDelete.forEach((filename) => {
fs.rm(filename, (e) => { fs.rm(filename, (e) => {
if (e) logError(e); if (e) {
logger.error("cleanup.delete_failed", {filename, error: e});
logError(e);
} else {
logger.debug("cleanup.deleted", {filename});
}
}); });
}); });
} }
+5 -4
View File
@@ -1,7 +1,9 @@
import {exec} from "node:child_process"; import {exec} from "node:child_process";
import {promisify} from "node:util"; import {promisify} from "node:util";
import {appLogger} from "../logging/logger";
const execAsync = promisify(exec); const execAsync = promisify(exec);
const logger = appLogger.child("shell-command-runner");
export type ShellCommandResult = { export type ShellCommandResult = {
stdout: string | null | undefined; stdout: string | null | undefined;
@@ -60,18 +62,17 @@ export class ShellCommandRunner {
maxBuffer: 64 * 1024, maxBuffer: 64 * 1024,
}); });
if (stdout) { if (stdout) {
console.log("COMMAND: ", command, "\n", "Output:", stdout); logger.debug("command.stdout", {command, stdout});
} }
if (stderr) { if (stderr) {
console.error("COMMAND: ", command, "\n", "Error:", stderr); logger.warn("command.stderr", {command, stderr});
} }
return {stdout, stderr}; return {stdout, stderr};
} catch (error: unknown) { } catch (error: unknown) {
const err = error as Partial<Error & {code: unknown; stdout: string; stderr: string}>; const err = error as Partial<Error & {code: unknown; stdout: string; stderr: string}>;
console.error("Error code:", err.code); logger.error("command.failed", {command, code: err.code, stderr: err.stderr, error});
console.error("Stderr:", err.stderr);
return {stdout: err.stdout ?? null, stderr: err.stderr ?? err.message ?? String(error)}; return {stdout: err.stdout ?? null, stderr: err.stderr ?? err.message ?? String(error)};
} }
+27 -3
View File
@@ -11,6 +11,10 @@
* queue always honors `parameters.retry_after` and requeues the task. * queue always honors `parameters.retry_after` and requeues the task.
*/ */
import {appLogger} from "../logging/logger";
const logger = appLogger.child("telegram-api-queue");
export type TelegramChatId = number | string; export type TelegramChatId = number | string;
export type TelegramChatType = string; export type TelegramChatType = string;
@@ -345,6 +349,7 @@ export class TelegramApiQueue {
this.globalBucket = new SlidingWindowRateLimit(this.options.globalLimit); this.globalBucket = new SlidingWindowRateLimit(this.options.globalLimit);
this.editBucket = new SlidingWindowRateLimit(this.options.editLimit); this.editBucket = new SlidingWindowRateLimit(this.options.editLimit);
this.bucketIdleMs = Math.max(this.options.perChatLimit.intervalMs, this.options.groupChatLimit.intervalMs) * 2; this.bucketIdleMs = Math.max(this.options.perChatLimit.intervalMs, this.options.groupChatLimit.intervalMs) * 2;
logger.debug("created", {maxConcurrent: this.options.maxConcurrent, maxAttempts: this.options.maxAttempts, maxQueueSize: this.options.maxQueueSize});
} }
get stats(): TelegramApiQueueStats { get stats(): TelegramApiQueueStats {
@@ -356,9 +361,18 @@ export class TelegramApiQueue {
} }
enqueue<T>(task: TelegramApiTask<T>, options: TelegramApiQueueTaskOptions = {}): Promise<T> { enqueue<T>(task: TelegramApiTask<T>, options: TelegramApiQueueTaskOptions = {}): Promise<T> {
if (this.closed) return Promise.reject(createClosedError()); if (this.closed) {
if (this.queue.length >= this.options.maxQueueSize) return Promise.reject(createQueueOverflowError(this.options.maxQueueSize)); logger.warn("enqueue.rejected.closed", {method: options.method, chatId: options.chatId});
if (options.signal?.aborted) return Promise.reject(createAbortError()); return Promise.reject(createClosedError());
}
if (this.queue.length >= this.options.maxQueueSize) {
logger.error("enqueue.rejected.overflow", {method: options.method, chatId: options.chatId, queued: this.queue.length, maxQueueSize: this.options.maxQueueSize});
return Promise.reject(createQueueOverflowError(this.options.maxQueueSize));
}
if (options.signal?.aborted) {
logger.debug("enqueue.rejected.aborted", {method: options.method, chatId: options.chatId});
return Promise.reject(createAbortError());
}
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const entry: QueueEntry<T> = { const entry: QueueEntry<T> = {
@@ -376,6 +390,7 @@ export class TelegramApiQueue {
this.attachAbortHandler(entry); this.attachAbortHandler(entry);
this.insertEntry(entry as QueueEntry<unknown>); this.insertEntry(entry as QueueEntry<unknown>);
logger.trace("enqueue.accepted", {taskId: entry.id, method: options.method, chatId: options.chatId, priority: options.priority, queued: this.queue.length, running: this.running});
this.pump(); this.pump();
}); });
} }
@@ -390,12 +405,14 @@ export class TelegramApiQueue {
close(reason: unknown = createClosedError()): void { close(reason: unknown = createClosedError()): void {
this.closed = true; this.closed = true;
logger.warn("closed", {queued: this.queue.length, running: this.running, reason});
if (this.timer) { if (this.timer) {
clearTimeout(this.timer); clearTimeout(this.timer);
this.timer = null; this.timer = null;
} }
const queued = this.queue; const queued = this.queue;
logger.debug("close.cancel_queued", {queued: queued.length});
this.queue = []; this.queue = [];
for (const entry of queued) { for (const entry of queued) {
this.cleanupAbortHandler(entry); this.cleanupAbortHandler(entry);
@@ -409,6 +426,7 @@ export class TelegramApiQueue {
clear(reason: unknown = new Error("Telegram API queue was cleared")): void { clear(reason: unknown = new Error("Telegram API queue was cleared")): void {
const queued = this.queue; const queued = this.queue;
logger.warn("cleared", {queued: queued.length, running: this.running, reason});
this.queue = []; this.queue = [];
for (const entry of queued) { for (const entry of queued) {
this.cleanupAbortHandler(entry); this.cleanupAbortHandler(entry);
@@ -435,6 +453,7 @@ export class TelegramApiQueue {
this.cleanupAbortHandler(entry); this.cleanupAbortHandler(entry);
entry.state = "cancelled"; entry.state = "cancelled";
logger.debug("entry.cancelled", {taskId: entry.id, method: entry.options.method, chatId: entry.options.chatId});
entry.reject(createAbortError()); entry.reject(createAbortError());
this.resolveIdleIfNeeded(); this.resolveIdleIfNeeded();
} }
@@ -456,6 +475,7 @@ export class TelegramApiQueue {
} }
if (selection.delayMs > 0) { if (selection.delayMs > 0) {
logger.trace("pump.delayed", {delayMs: selection.delayMs, queued: this.queue.length, running: this.running});
this.schedule(selection.delayMs); this.schedule(selection.delayMs);
return; return;
} }
@@ -497,6 +517,7 @@ export class TelegramApiQueue {
this.cleanupAbortHandler(entry); this.cleanupAbortHandler(entry);
this.recordStart(entry, Date.now()); this.recordStart(entry, Date.now());
this.running++; this.running++;
logger.trace("entry.started", {taskId: entry.id, method: entry.options.method, chatId: entry.options.chatId, attempt: entry.attempt, queued: this.queue.length, running: this.running});
void this.runEntry(entry); void this.runEntry(entry);
} }
@@ -509,6 +530,7 @@ export class TelegramApiQueue {
signal: entry.options.signal, signal: entry.options.signal,
}); });
entry.state = "settled"; entry.state = "settled";
logger.trace("entry.settled", {taskId: entry.id, method: entry.options.method, chatId: entry.options.chatId, attempt: entry.attempt});
entry.resolve(result); entry.resolve(result);
} catch (error) { } catch (error) {
const retry = this.getRetryDecision(error, entry); const retry = this.getRetryDecision(error, entry);
@@ -523,6 +545,7 @@ export class TelegramApiQueue {
} else { } else {
this.attachAbortHandler(entry); this.attachAbortHandler(entry);
this.insertEntry(entry); this.insertEntry(entry);
logger.warn("entry.retry", {taskId: entry.id, method: entry.options.method, chatId: entry.options.chatId, attempt: entry.attempt - 1, delayMs: retry.delayMs, reason: retry.reason, error});
this.options.onRetry?.({ this.options.onRetry?.({
taskId: entry.id, taskId: entry.id,
method: entry.options.method, method: entry.options.method,
@@ -535,6 +558,7 @@ export class TelegramApiQueue {
} }
} else { } else {
entry.state = "settled"; entry.state = "settled";
logger.error("entry.failed", {taskId: entry.id, method: entry.options.method, chatId: entry.options.chatId, attempt: entry.attempt, error});
entry.reject(this.closed ? createClosedError() : error); entry.reject(this.closed ? createClosedError() : error);
} }
} finally { } finally {
+33 -15
View File
@@ -1,5 +1,5 @@
import * as si from "systeminformation"; import * as si from "systeminformation";
import {redactLogValue} from "../ai/ai-logger"; import {appLogger} from "../logging/logger";
import {Command} from "../base/command"; import {Command} from "../base/command";
import {CallbackCommand} from "../base/callback-command"; import {CallbackCommand} from "../base/callback-command";
import { import {
@@ -49,6 +49,9 @@ import {ShellCommandResult, ShellCommandRunner} from "./shell-command-runner";
const imageProcessingSemaphore = new AsyncSemaphore(2); const imageProcessingSemaphore = new AsyncSemaphore(2);
const fileWriteLocks = new KeyedAsyncLock(); const fileWriteLocks = new KeyedAsyncLock();
const logger = appLogger.child("utils");
const requirementLogger = appLogger.child("requirements");
const messageLogger = appLogger.child("messages");
export const ignore = () => { export const ignore = () => {
}; };
@@ -66,7 +69,7 @@ export const ignoreIfMarkupFailed = (e: Error | TelegramError) => {
}; };
export const logError = (e: Error | TelegramError | string | unknown) => { export const logError = (e: Error | TelegramError | string | unknown) => {
console.error(redactLogValue(e)); appLogger.error("error", {error: e});
}; };
export const errorPlaceholder = async (msg: Message) => { export const errorPlaceholder = async (msg: Message) => {
@@ -148,7 +151,7 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m
!Environment.CHAT_IDS_WHITELIST.has(chatId) && !Environment.CHAT_IDS_WHITELIST.has(chatId) &&
!Environment.ADMIN_IDS.has(chatId) && !Environment.ADMIN_IDS.has(chatId) &&
!Environment.ADMIN_IDS.has(fromId)) { !Environment.ADMIN_IDS.has(fromId)) {
console.log(`${title}: chatId whitelist ignored.`); requirementLogger.debug("rejected.chat_whitelist", {title, chatId, fromId});
return false; return false;
} }
@@ -172,19 +175,19 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m
}; };
if (reqs.isRequiresBotCreator() && fromId !== Environment.CREATOR_ID) { if (reqs.isRequiresBotCreator() && fromId !== Environment.CREATOR_ID) {
console.log(`${title}: creatorId is bad`); requirementLogger.debug("rejected.creator", {title, fromId});
await notifyUser(Environment.notBotCreatorText); await notifyUser(Environment.notBotCreatorText);
return false; return false;
} }
if (reqs.isRequiresBotAdmin() && !Environment.ADMIN_IDS.has(fromId)) { if (reqs.isRequiresBotAdmin() && !Environment.ADMIN_IDS.has(fromId)) {
console.log(`${title}: adminId is bad`); requirementLogger.debug("rejected.bot_admin", {title, fromId});
await notifyUser(Environment.notBotAdministratorText); await notifyUser(Environment.notBotAdministratorText);
return false; return false;
} }
if (reqs.isRequiresChat() && msg?.chat?.type === "private") { if (reqs.isRequiresChat() && msg?.chat?.type === "private") {
console.log(`${title}: chatId is bad`); requirementLogger.debug("rejected.chat_required", {title, chatId, chatType});
await notifyUser(Environment.notAChatText); await notifyUser(Environment.notAChatText);
return false; return false;
} }
@@ -193,7 +196,7 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m
const member = await bot.getChatMember({chat_id: chatId, user_id: fromId}); const member = await bot.getChatMember({chat_id: chatId, user_id: fromId});
if (!isMemberAdmin(member)) { if (!isMemberAdmin(member)) {
console.log(`${title}: chatAdminId is bad`); requirementLogger.debug("rejected.chat_admin", {title, chatId, fromId});
await notifyUser(Environment.notChatAdministratorText); await notifyUser(Environment.notChatAdministratorText);
return false; return false;
} }
@@ -203,14 +206,14 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m
const member = await bot.getChatMember({chat_id: chatId, user_id: botUser.id}); const member = await bot.getChatMember({chat_id: chatId, user_id: botUser.id});
if (!isMemberAdmin(member)) { if (!isMemberAdmin(member)) {
console.log(`${title}: botChatAdminId is bad`); requirementLogger.debug("rejected.bot_chat_admin", {title, chatId});
await notifyUser(Environment.botNotChatAdministratorText); await notifyUser(Environment.botNotChatAdministratorText);
return false; return false;
} }
} }
if (reqs.isRequiresReply() && !msg?.reply_to_message) { if (reqs.isRequiresReply() && !msg?.reply_to_message) {
console.log(`${title}: replyMessage is bad`); requirementLogger.debug("rejected.reply_required", {title, chatId, messageId});
await notifyUser(Environment.replyRequiredText); await notifyUser(Environment.replyRequiredText);
return false; return false;
} }
@@ -242,7 +245,7 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m
} }
if (!originalFromId || (fromId !== originalFromId && fromId !== Environment.CREATOR_ID)) { if (!originalFromId || (fromId !== originalFromId && fromId !== Environment.CREATOR_ID)) {
console.log(`${title}: sameUser is bad`); requirementLogger.debug("rejected.same_user", {title, chatId, fromId, originalFromId});
await notifyUser(Environment.onlyOriginalAuthorText); await notifyUser(Environment.onlyOriginalAuthorText);
return false; return false;
} }
@@ -2090,19 +2093,23 @@ export function photoPathByUniqueId(uniqueId: string): string {
} }
export async function processMyChatMember(u: ChatMemberUpdated): Promise<void> { export async function processMyChatMember(u: ChatMemberUpdated): Promise<void> {
console.log("my_chat_member", u); messageLogger.debug("my_chat_member", {update: u});
} }
export async function processGuestMessage(msg: Message): Promise<void> { export async function processGuestMessage(msg: Message): Promise<void> {
// return processNewMessage(msg, true); // return processNewMessage(msg, true);
console.log("NEW_GUEST_MESSAGE", msg); messageLogger.debug("guest_message.received", {message: msg});
} }
export async function processNewMessage(msg: Message, isGuest?: boolean): Promise<void> { export async function processNewMessage(msg: Message, isGuest?: boolean): Promise<void> {
console.log(isGuest ? "NEW_GUEST_MESSAGE" : "NEW_MESSAGE", msg); messageLogger.debug(isGuest ? "guest_message.received" : "message.received", {message: msg});
if (!msg.from) return; if (!msg.from) {
messageLogger.debug("message.skipped.no_sender", {chatId: msg.chat?.id, messageId: msg.message_id});
return;
}
const startedAt = Date.now();
const from = msg.from; const from = msg.from;
Environment.reloadRuntimeConfigIfChanged(); Environment.reloadRuntimeConfigIfChanged();
@@ -2115,6 +2122,7 @@ export async function processNewMessage(msg: Message, isGuest?: boolean): Promis
UserStore.put(from) UserStore.put(from)
] ]
); );
messageLogger.debug("message.persisted", {chatId: msg.chat.id, messageId: msg.message_id, fromId: from.id, duration: logger.duration(startedAt)});
storedMsg = results[0]; storedMsg = results[0];
locale = await resolveInterfaceLocaleForUser(from.id, from.language_code); locale = await resolveInterfaceLocaleForUser(from.id, from.language_code);
@@ -2193,7 +2201,15 @@ export async function processNewMessage(msg: Message, isGuest?: boolean): Promis
const hasAudioAttachment = !!msg.voice || !!msg.audio || !!msg.document?.mime_type?.startsWith("audio/") const hasAudioAttachment = !!msg.voice || !!msg.audio || !!msg.document?.mime_type?.startsWith("audio/")
|| !!msg.video_note; || !!msg.video_note;
const hasImageAttachment = !!msg.photo?.length || !!msg.document?.mime_type?.startsWith("image/"); const hasImageAttachment = !!msg.photo?.length || !!msg.document?.mime_type?.startsWith("image/");
if (executed || (!cmdText && !hasAudioAttachment && !hasImageAttachment)) return; if (executed) {
messageLogger.debug("message.command_executed", {chatId: msg.chat.id, messageId: msg.message_id, command: cmd?.title});
return;
}
if (!cmdText && !hasAudioAttachment && !hasImageAttachment) {
messageLogger.debug("message.skipped.empty", {chatId: msg.chat.id, messageId: msg.message_id});
return;
}
const hasConfiguredPrefix = Environment.BOT_PREFIX.length > 0; const hasConfiguredPrefix = Environment.BOT_PREFIX.length > 0;
const startsWithPrefix = hasConfiguredPrefix && cmdText.toLowerCase().startsWith(Environment.BOT_PREFIX.toLowerCase()); const startsWithPrefix = hasConfiguredPrefix && cmdText.toLowerCase().startsWith(Environment.BOT_PREFIX.toLowerCase());
@@ -2223,12 +2239,14 @@ export async function processNewMessage(msg: Message, isGuest?: boolean): Promis
}); });
if (!isReplyToBot && !hasPrefix && !hasBotMention && !hasAudioAttachment) { if (!isReplyToBot && !hasPrefix && !hasBotMention && !hasAudioAttachment) {
messageLogger.debug("message.skipped.not_addressed", {chatId: msg.chat.id, messageId: msg.message_id});
return; return;
} }
} }
const provider = await resolveEffectiveAiProviderForUser(from.id); const provider = await resolveEffectiveAiProviderForUser(from.id);
messageLogger.info("ai.dispatch", {chatId: msg.chat.id, messageId: msg.message_id, fromId: from.id, provider});
void runUnifiedAi({ void runUnifiedAi({
provider: provider, provider: provider,
msg: msg, msg: msg,