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 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,
};
}
export * from "../logging/ai-logger";
+12 -2
View File
@@ -1,5 +1,8 @@
import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider";
import {appLogger} from "../logging/logger";
const logger = appLogger.child("ai-provider-queue");
export type AiRequestQueueTarget = {
provider: AiProvider;
@@ -31,6 +34,7 @@ class AiProviderRequestQueue {
enqueue<T>(target: AiRequestQueueTarget, options: EnqueueOptions<T>): Promise<T> {
if (options.signal?.aborted) {
logger.debug("enqueue.rejected.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl});
return Promise.reject(new Error("Aborted"));
}
@@ -53,12 +57,14 @@ class AiProviderRequestQueue {
const removed = this.removeWaitingEntry(entry);
if (!removed) return;
logger.debug("entry.cancelled", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queueKey});
reject(new Error("Aborted"));
this.schedule(target);
};
options.signal?.addEventListener("abort", entry.abortHandler, {once: true});
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);
});
}
@@ -132,12 +138,14 @@ class AiProviderRequestQueue {
}
if (entry.signal?.aborted) {
logger.debug("entry.skipped.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl, queueKey});
entry.reject(new Error("Aborted"));
continue;
}
entry.started = true;
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);
}
@@ -150,7 +158,9 @@ class AiProviderRequestQueue {
private async runEntry(entry: QueueEntry): Promise<void> {
try {
entry.resolve(await entry.run());
logger.debug("entry.done", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl});
} catch (e) {
logger.error("entry.failed", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl, error: e});
entry.reject(e);
} finally {
this.setActiveCount(entry.queueKey, this.activeCount(entry.queueKey) - 1);
@@ -168,10 +178,10 @@ class AiProviderRequestQueue {
})).then(results => {
for (const result of results) {
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 {
+31 -5
View File
@@ -8,6 +8,7 @@ import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment
import {performFFmpeg} from "../util/ffmpeg";
import ffmpeg from "fluent-ffmpeg";
import {AsyncSemaphore, KeyedAsyncLock} from "../util/async-lock";
import {appLogger} from "../logging/logger";
export type AiDownloadedFile = {
kind: StoredAttachmentKind;
@@ -20,6 +21,7 @@ export type AiDownloadedFile = {
const cachePathLocks = new KeyedAsyncLock();
const ffmpegSemaphore = new AsyncSemaphore(2);
const logger = appLogger.child("attachments");
function safeFileName(value: string): string {
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> {
const startedAt = Date.now();
logger.debug("download.start", {kind, fileId, fileName, mimeType});
const file = await bot.getFile({file_id: fileId});
const finalFileName = fileNameWithExtension(fileName, mimeType, file.file_path);
const location = cachePathFor(kind, fileUniqueId, fileId, finalFileName);
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);
if (!buffer) return;
if (!buffer) {
logger.warn("download.empty", {kind, fileId, telegramFilePath: file.file_path});
return;
}
const tempLocation = `${location}.${process.pid}.${Date.now()}.tmp`;
fs.mkdirSync(path.dirname(location), {recursive: true});
fs.writeFileSync(tempLocation, buffer);
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};
}
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 () => {
if (fs.existsSync(output)) return;
if (fs.existsSync(output)) {
logger.trace("audio.convert.cache_hit", {output});
return;
}
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`;
try {
@@ -125,14 +144,16 @@ async function convertAudioToWav(input: string, output: string, noVideo = false)
.toFormat("wav")
.save(tempOutput)
.on("progress", (progress) => {
console.log("progress", progress);
logger.trace("audio.convert.progress", {input, output, progress});
});
});
fs.renameSync(tempOutput, output);
logger.debug("audio.convert.done", {input, output, duration: logger.duration(startedAt)});
} catch (e) {
if (fs.existsSync(tempOutput)) {
fs.rmSync(tempOutput, {force: true});
}
logger.error("audio.convert.failed", {input, output, error: e});
throw e;
}
});
@@ -140,7 +161,9 @@ async function convertAudioToWav(input: string, output: string, noVideo = false)
}
export async function cacheMessageAttachments(msg: Message): Promise<StoredAttachment[]> {
const startedAt = Date.now();
const result: StoredAttachment[] = [];
logger.debug("message.cache.start", {chatId: msg.chat?.id, messageId: msg.message_id});
try {
if (msg.photo?.length) {
@@ -202,10 +225,12 @@ export async function cacheMessageAttachments(msg: Message): Promise<StoredAttac
logError(e);
}
logger.debug("message.cache.done", {chatId: msg.chat?.id, messageId: msg.message_id, attachments: result.length, duration: logger.duration(startedAt)});
return result;
}
export function attachmentsToDownloadedFiles(attachments: StoredAttachment[]): AiDownloadedFile[] {
logger.trace("downloaded_files.build", {attachments: attachments.length});
return attachments
.filter(attachment => fs.existsSync(attachment.cachePath))
.map(attachment => ({
@@ -219,6 +244,7 @@ export function attachmentsToDownloadedFiles(attachments: StoredAttachment[]): A
}
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.
for (const file of files) {
file.buffer = Buffer.alloc(0);
+6 -2
View File
@@ -1,4 +1,7 @@
import axios from "axios";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("brave-search");
import {Environment} from "../../common/environment";
import {logError} from "../../util/utils";
import {AiTool} from "../tool-types";
@@ -264,7 +267,8 @@ function normalizeBraveResultFilter(value: unknown): string {
}
export async function webSearch(args?: Record<string, unknown>) {
console.log("braveSearch()");
const startedAt = Date.now();
logger.info("start", {args});
try {
const query = asNonEmptyString(args?.query);
@@ -370,7 +374,7 @@ export async function webSearch(args?: Record<string, unknown>) {
response: data ?? null,
};
} 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 {asNonEmptyString} from "./utils";
import fs from "node:fs";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("create-note");
export type CreateNoteResult =
| { success: true; filePath: string }
@@ -38,7 +41,8 @@ export const createNoteTool = {
export async function createNote(
args?: Record<string, unknown>
): Promise<CreateNoteResult> {
console.log("CREATE_NOTE; ARGS: ", args);
const startedAt = Date.now();
logger.debug("start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
@@ -76,8 +80,10 @@ export async function createNote(
}
await writeFile(notesRootFile, rootContent, "utf-8");
logger.debug("done", {fileName, filePath: newFilePath, duration: logger.duration(startedAt)});
return {success: true, filePath: newFilePath};
} catch (error) {
logger.error("failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
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 {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("notes");
export type NoteListItem = {
fileName: string;
@@ -58,7 +61,8 @@ export const getNoteContentTool = {
} satisfies AiTool;
export async function listNotes(): Promise<ListNotesResult> {
console.log("LIST_NOTES");
const startedAt = Date.now();
logger.debug("list.start");
try {
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));
logger.debug("list.done", {notes: notes.length, duration: logger.duration(startedAt)});
return {success: true, notes};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to list notes: ${errorMessage}`};
}
@@ -101,7 +107,8 @@ export async function listNotes(): Promise<ListNotesResult> {
export async function getNoteContent(
args?: Record<string, unknown>,
): Promise<GetNoteContentResult> {
console.log("GET_NOTE_CONTENT; ARGS: ", args);
const startedAt = Date.now();
logger.debug("get_content.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
@@ -118,6 +125,7 @@ export async function getNoteContent(
const normalizedFileName = path.basename(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 {
success: true,
fileName: normalizedFileName,
@@ -127,6 +135,7 @@ export async function getNoteContent(
content,
};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to read note: ${errorMessage}`};
}
@@ -219,7 +228,8 @@ export const deleteNoteTool = {
export async function updateNoteContent(
args?: Record<string, unknown>,
): Promise<UpdateNoteContentResult> {
console.log("UPDATE_NOTE_CONTENT; ARGS: ", args);
const startedAt = Date.now();
logger.debug("update_content.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
@@ -239,9 +249,11 @@ export async function updateNoteContent(
try {
await readFile(noteFilePath, "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};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to update note: ${errorMessage}`};
}
@@ -250,7 +262,8 @@ export async function updateNoteContent(
export async function deleteNote(
args?: Record<string, unknown>,
): Promise<DeleteNoteResult> {
console.log("DELETE_NOTE; ARGS: ", args);
const startedAt = Date.now();
logger.debug("delete.start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
@@ -265,9 +278,11 @@ export async function deleteNote(
try {
await unlink(noteFilePath);
await removeNoteLinkFromRoot(noteFilePath);
logger.debug("delete.done", {fileName, filePath: noteFilePath, duration: logger.duration(startedAt)});
return {success: true, filePath: noteFilePath};
} catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to delete note: ${errorMessage}`};
}
+7 -1
View File
@@ -1,5 +1,8 @@
import {AiTool} from "../tool-types";
import axios from "axios";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("market-rates");
export const getMarketRatesTool = {
type: "function",
@@ -59,11 +62,14 @@ export const marketRatesToolPrompt = [
].join("\n");
export async function getMarketRates(): Promise<unknown | undefined> {
const startedAt = Date.now();
try {
logger.info("start");
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;
} catch (e: unknown) {
console.error("GET_MARKET_RATES", e);
logger.error("failed", {duration: logger.duration(startedAt), error: e});
return undefined;
}
}
+16 -12
View File
@@ -5,6 +5,9 @@ import path from "node:path";
import {AiTool} from "../tool-types";
import {Environment} from "../../common/environment";
import {randomUUID} from "node:crypto";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("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);
console.timeEnd("python.syntax");
logger.debug("syntax.done", {duration: logger.duration(syntaxStartedAt), ok: syntax.ok});
if (!syntax.ok) {
return syntax;
}
console.time("python.execution");
const executionStartedAt = Date.now();
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;
}
@@ -293,7 +296,8 @@ async function executePythonCode(
args: PythonInterpreterArgs,
options: PythonInterpreterOptions = {},
): 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 =
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,
});
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({
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) {
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR TIMED OUT", new Date());
logger.warn("process.timeout", {duration: logger.duration(startedAt)});
return {
ok: false,
phase: "execution",
@@ -365,7 +369,7 @@ async function executePythonCode(
}
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 {
ok: false,
@@ -382,7 +386,7 @@ async function executePythonCode(
}
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 {
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 {
artifacts,
@@ -420,7 +424,7 @@ async function executePythonCode(
skippedArtifacts,
};
} catch (error) {
console.log("EXECUTE_PYTHON_CODE", "RESULT ERROR", new Date());
logger.error("execute.failed", {duration: logger.duration(startedAt), error});
return {
ok: false,
phase: "internal",
+10 -2
View File
@@ -1,6 +1,9 @@
import {getToolHandlers} from "./registry";
import {normalizeToolArguments} from "./utils";
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("runtime");
export type ToolRuntimeContext = {
pythonInputFiles?: PythonInterpreterInputFile[];
@@ -16,7 +19,9 @@ export async function executeToolCall(
args?: unknown,
context: ToolRuntimeContext = {},
): Promise<string> {
const startedAt = Date.now();
const handler = getToolHandlers()[name];
logger.info("execute.start", {name, args});
if (!handler) {
return stringifyToolResult({
@@ -35,14 +40,17 @@ export async function executeToolCall(
});
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;
}
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) {
logger.error("execute.failed", {name, duration: logger.duration(startedAt), error});
return stringifyToolResult({
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 {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("search-notes");
export type SearchNoteMatchedField = "file_name" | "title" | "content";
@@ -51,7 +54,8 @@ export const searchNotesTool = {
export async function searchNotes(
args?: Record<string, unknown>,
): Promise<SearchNotesResult> {
console.log("SEARCH_NOTES; ARGS: ", args);
const startedAt = Date.now();
logger.debug("start", {args});
const query = asNonEmptyString(args?.query) ?? "";
if (!query.trim().length) {
@@ -127,6 +131,7 @@ export async function searchNotes(
.sort((a, b) => b.score - a.score)
.slice(0, limit);
logger.debug("done", {query, limit, results: results.length, duration: logger.duration(startedAt)});
return {success: true, results};
} catch (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 {buildSafeNoteFilePath} from "./list-notes";
import z from "zod";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("get-note-file");
export type NoteFileAttachment = {
type: "local_file";
@@ -64,7 +67,7 @@ export const getNoteFileTool = {
export async function getNoteFile(
args?: Record<string, unknown>,
): Promise<GetNoteFileResult> {
console.log("GET_NOTE_FILE; ARGS: ", args);
logger.debug("start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
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;
} catch (error) {
+8 -3
View File
@@ -1,5 +1,8 @@
import {Ollama} from "ollama";
import {z} from "zod";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("utils");
export function asNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0
@@ -79,14 +82,15 @@ export async function unloadAllOllamaModels(ollama: Ollama, exceptFor?: string[]
);
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) {
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> {
try {
logger.info("ollama.load.start", {model, contextLength});
await ollama.generate({
model: model,
stream: false,
@@ -95,9 +99,10 @@ export async function loadOllamaModel(model: string, ollama: Ollama, contextLeng
num_ctx: contextLength
}
});
logger.info("ollama.load.done", {model, contextLength});
return true;
} catch (e: unknown) {
console.error("Error loading Ollama model:", model);
logger.error("ollama.load.failed", {model, contextLength, error: e});
return false;
}
}
+9 -4
View File
@@ -1,4 +1,7 @@
import axios from "axios";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("weather");
import {Environment} from "../../common/environment";
import {logError} from "../../util/utils";
import {AiTool} from "../tool-types";
@@ -43,7 +46,8 @@ export const weatherToolPrompt = [
].join("\n");
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 {
const city = asNonEmptyString(args?.city);
const lang = asNonEmptyString(args?.lang);
@@ -61,7 +65,7 @@ export async function getWeather(args?: Record<string, unknown>): Promise<Record
appid: apiKey,
},
})).data[0];
console.log("GEOCODE_RESPONSE", geocodeResponse);
logger.debug("geocode.done", {city, country: geocodeResponse?.country, hasResult: !!geocodeResponse, geocodeResponse});
if (!geocodeResponse) {
return {
ok: false,
@@ -83,7 +87,7 @@ export async function getWeather(args?: Record<string, unknown>): Promise<Record
...(lang ? {lang} : {}),
},
})).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 sys = response.sys;
@@ -138,9 +142,10 @@ export async function getWeather(args?: Record<string, unknown>): Promise<Record
},
};
} catch (e: unknown) {
logger.error("failed", {duration: logger.duration(startedAt), error: e});
logError(e);
return null;
} 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 {GeminiMessage} from "./gemini-chat-message";
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";
+1 -1
View File
@@ -5,7 +5,7 @@ import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime";
import {MistralChatMessage} from "./mistral-chat-message";
import {createMistralClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "./ai-logger";
import {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";
+1 -1
View File
@@ -19,7 +19,7 @@ import {getWeatherTool} from "./tools/weather";
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
import {createOllamaClient} from "./ai-runtime-target";
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "./ai-logger";
import {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 {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 {ChatCompletionCreateParamsNonStreaming, ChatCompletionCreateParamsStreaming} from "openai/resources/chat/completions";
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";
@@ -47,8 +47,7 @@ export async function runOpenAi(
const request: ResponseCreateParamsNonStreaming = {
model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[],
// TODO: 13.05.2026, Danil Nikolaev: fix
tools: getOpenAIResponsesToolsWithImage(config) as any,
tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsNonStreaming["tools"],
instructions: config.systemPrompt,
};
const response = await openAi.responses.create(request, {signal}) as unknown as OpenAiResponseLike;
@@ -105,8 +104,7 @@ export async function runOpenAi(
model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[],
stream: true,
// TODO: 13.05.2026, Danil Nikolaev: fix
tools: getOpenAIResponsesToolsWithImage(config) as any,
tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsStreaming["tools"],
};
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 {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
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 {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 {
if (content?.length && !toolCalls?.length && !thinking) {
// console.log("ROUND_STATUS", "null");
return null;
}
@@ -452,7 +451,6 @@ export function roundStatus(round: number, firstRoundStatus: string, content?: s
: round === 0 ? firstRoundStatus
: Environment.waitThinkText;
// console.log("ROUND_STATUS", status);
return status;
}
@@ -1085,11 +1083,11 @@ export function appendTranscriptToChatMessages(
return partType === "input_text" || partType === "input_image";
});
lastUser.content.push(
(usesOpenAiResponsesParts
? {type: "input_text", text}
: {type: "text", text}) as any,
);
if (usesOpenAiResponsesParts) {
(lastUser.content as ResponseInputMessageContentList).push({type: "input_text", text});
} else {
(lastUser.content as OpenAiCompatibleContentPart[]).push({type: "text", text});
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import {Tool} from "ollama";
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";
type RankedToolStep = {
+1 -1
View File
@@ -16,7 +16,7 @@ import {OllamaChatMessage} from "./ollama-chat-message";
import {GeminiMessage} from "./gemini-chat-message";
import {buildAiRegenerateCallbackData} from "./regenerate-callback";
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 {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 {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
import {Environment} from "../common/environment";
import {appLogger} from "../logging/logger";
const logger = appLogger.child("command:choice");
export class Choice extends Command {
command = "choice";
@@ -12,7 +15,7 @@ export class Choice extends Command {
description = Environment.commandDescriptions.choice;
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] || "";
+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 {AiProvider} from "../model/ai-provider";
import {ProviderGetModelCommand} from "./provider-model-command";
export class GeminiGetModel extends Command {
title = Environment.commandTitles.geminiGetModel;
description = Environment.commandDescriptions.geminiGetModel;
async execute(msg: Message): Promise<void> {
await replyToMessage({message: msg, text: await formatRuntimeModelInfo(AiProvider.GEMINI)}).catch(logError);
export class GeminiGetModel extends ProviderGetModelCommand {
constructor() {
super({
provider: AiProvider.GEMINI,
title: Environment.commandTitles.geminiGetModel,
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 {AiProvider} from "../model/ai-provider";
import {ProviderListModelsCommand} from "./provider-model-command";
export class GeminiListModels extends Command {
title = Environment.commandTitles.geminiListModels;
description = Environment.commandDescriptions.geminiListModels;
requirements = Requirements.Build(Requirement.BOT_CREATOR);
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);
}
export class GeminiListModels extends ProviderListModelsCommand {
constructor() {
super({
provider: AiProvider.GEMINI,
title: Environment.commandTitles.geminiListModels,
description: Environment.commandDescriptions.geminiListModels,
});
}
}
+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 {AiProvider} from "../model/ai-provider";
import {ProviderSetModelCommand} from "./provider-model-command";
export class GeminiSetModel extends Command {
argsMode = "required" as const;
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);
export class GeminiSetModel extends ProviderSetModelCommand {
constructor() {
super({
provider: AiProvider.GEMINI,
title: Environment.commandTitles.geminiSetModel,
description: Environment.commandDescriptions.geminiSetModel,
});
}
}
+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 {AiProvider} from "../model/ai-provider";
import {ProviderGetModelCommand} from "./provider-model-command";
export class MistralGetModel extends Command {
title = Environment.commandTitles.mistralGetModel;
description = Environment.commandDescriptions.mistralGetModel;
async execute(msg: Message): Promise<void> {
await replyToMessage({message: msg, text: await formatRuntimeModelInfo(AiProvider.MISTRAL)}).catch(logError);
export class MistralGetModel extends ProviderGetModelCommand {
constructor() {
super({
provider: AiProvider.MISTRAL,
title: Environment.commandTitles.mistralGetModel,
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 {AiProvider} from "../model/ai-provider";
import {ProviderListModelsCommand} from "./provider-model-command";
export class MistralListModels extends Command {
title = Environment.commandTitles.mistralListModels;
description = Environment.commandDescriptions.mistralListModels;
requirements = Requirements.Build(Requirement.BOT_CREATOR);
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);
}
export class MistralListModels extends ProviderListModelsCommand {
constructor() {
super({
provider: AiProvider.MISTRAL,
title: Environment.commandTitles.mistralListModels,
description: Environment.commandDescriptions.mistralListModels,
});
}
}
+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 {AiProvider} from "../model/ai-provider";
import {ProviderSetModelCommand} from "./provider-model-command";
export class MistralSetModel extends Command {
argsMode = "required" as const;
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);
export class MistralSetModel extends ProviderSetModelCommand {
constructor() {
super({
provider: AiProvider.MISTRAL,
title: Environment.commandTitles.mistralSetModel,
description: Environment.commandDescriptions.mistralSetModel,
});
}
}
+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 {AiProvider} from "../model/ai-provider";
import {ProviderGetModelCommand} from "./provider-model-command";
export class OllamaGetModel extends Command {
title = Environment.commandTitles.ollamaGetModel;
description = Environment.commandDescriptions.ollamaGetModel;
async execute(msg: Message): Promise<void> {
await replyToMessage({message: msg, text: await formatRuntimeModelInfo(AiProvider.OLLAMA)}).catch(logError);
export class OllamaGetModel extends ProviderGetModelCommand {
constructor() {
super({
provider: AiProvider.OLLAMA,
title: Environment.commandTitles.ollamaGetModel,
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 {escapeHtml, logError, replyToMessage} from "../util/utils";
import {AiProvider} from "../model/ai-provider";
import {listProviderModels} from "../ai/provider-model-runtime";
import {createOllamaClient, resolveAiRuntimeTarget} from "../ai/ai-runtime-target";
import {ProviderListModelsCommand} from "./provider-model-command";
export class OllamaListModels extends Command {
title = Environment.commandTitles.ollamaListModels;
description = Environment.commandDescriptions.ollamaListModels;
requirements = Requirements.Build(Requirement.BOT_CREATOR);
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);
}
export class OllamaListModels extends ProviderListModelsCommand {
constructor() {
super({
provider: AiProvider.OLLAMA,
title: Environment.commandTitles.ollamaListModels,
description: Environment.commandDescriptions.ollamaListModels,
});
}
}
+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 {AiProvider} from "../model/ai-provider";
import {ProviderSetModelCommand} from "./provider-model-command";
export class OllamaSetModel extends Command {
argsMode = "required" as const;
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);
export class OllamaSetModel extends ProviderSetModelCommand {
constructor() {
super({
provider: AiProvider.OLLAMA,
title: Environment.commandTitles.ollamaSetModel,
description: Environment.commandDescriptions.ollamaSetModel,
});
}
}
+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 {AiProvider} from "../model/ai-provider";
import {ProviderGetModelCommand} from "./provider-model-command";
export class OpenAIGetModel extends Command {
title = Environment.commandTitles.openAiGetModel;
description = Environment.commandDescriptions.openAiGetModel;
async execute(msg: Message): Promise<void> {
await replyToMessage({message: msg, text: await formatRuntimeModelInfo(AiProvider.OPENAI)}).catch(logError);
export class OpenAIGetModel extends ProviderGetModelCommand {
constructor() {
super({
provider: AiProvider.OPENAI,
title: Environment.commandTitles.openAiGetModel,
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 {AiProvider} from "../model/ai-provider";
import {ProviderListModelsCommand} from "./provider-model-command";
export class OpenAIListModels extends Command {
title = Environment.commandTitles.openAiListModels;
description = Environment.commandDescriptions.openAiListModels;
requirements = Requirements.Build(Requirement.BOT_CREATOR);
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);
}
export class OpenAIListModels extends ProviderListModelsCommand {
constructor() {
super({
provider: AiProvider.OPENAI,
title: Environment.commandTitles.openAiListModels,
description: Environment.commandDescriptions.openAiListModels,
});
}
}
+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 {AiProvider} from "../model/ai-provider";
import {ProviderSetModelCommand} from "./provider-model-command";
export class OpenAISetModel extends Command {
argsMode = "required" as const;
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);
export class OpenAISetModel extends ProviderSetModelCommand {
constructor() {
super({
provider: AiProvider.OPENAI,
title: Environment.commandTitles.openAiSetModel,
description: Environment.commandDescriptions.openAiSetModel,
});
}
}
+11 -5
View File
@@ -21,6 +21,9 @@ import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {AsyncSemaphore} from "../util/async-lock";
import {Environment} from "../common/environment";
import {getLruMapValue, setLruMapValue} from "../util/lru-map";
import {appLogger} from "../logging/logger";
const logger = appLogger.child("command:quote");
try {
GlobalFonts.registerFromPath("./assets/Inter_18pt-Thin.ttf", "InterThin");
@@ -54,6 +57,8 @@ export class Quote extends Command {
if (!reply) return;
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();
if (quoteRaw.length === 0) {
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}
).catch(logError);
);
logger.debug("execute.done", {chatId, messageId: msg.message_id, bytes: png.length, duration: logger.duration(startedAt)});
} catch (e) {
logError(e);
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) {
console.warn(`Custom emoji ${customEmojiId} not found`);
logger.warn("custom_emoji.not_found", {customEmojiId});
return null;
}
const sticker = stickerSet[0];
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);
}
@@ -171,7 +177,7 @@ async function loadCustomEmoji(customEmojiId: string): Promise<CanvasImage | nul
setLruMapValue(customEmojiCache, customEmojiId, img, CUSTOM_EMOJI_CACHE_MAX_ENTRIES);
return img;
} catch (e) {
console.warn(`Failed to load custom emoji ${customEmojiId}:`, e);
logger.warn("custom_emoji.load_failed", {customEmojiId, error: e});
return null;
}
}
@@ -521,7 +527,7 @@ async function drawLine(ctx: SKRSContext2D, line: Segment[], x: number, baseline
ctx.drawImage(<Image>img, cx, y, emojiSize, emojiSize);
}
} catch (e) {
console.warn("Failed to draw custom emoji:", e);
logger.warn("custom_emoji.draw_failed", {error: e});
try {
const img = await loadEmoji("😥");
+2 -1
View File
@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import {parse as parseDotEnv} from "dotenv";
import {z} from "zod";
import {appLogger} from "../logging/logger";
import {saveData} from "../db/database";
import {Answers} from "../model/answers";
@@ -1851,7 +1852,7 @@ export class Environment {
Environment.lastRankerToolPromptMtimeMs = rankerToolPromptMtimeMs;
}
} 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 fs from "node:fs";
import path from "node:path";
import {appLogger} from "../logging/logger";
const logger = appLogger.child("localization");
export const DEFAULT_LOCALE = "en";
export const DEFAULT_LANGUAGE_CHOICE = "default";
@@ -235,7 +238,7 @@ export class Localization {
try {
bundles.set(locale, JSON.parse(fs.readFileSync(filePath, "utf8")) as LocalizationBundle);
} 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);
if (previous) bundles.set(locale, previous);
}
@@ -244,8 +247,9 @@ export class Localization {
Localization.bundles = bundles;
Localization.fileMtimeMs = mtimes;
Localization.fileSignature = signature;
logger.debug("reload.done", {force, locales: [...bundles.keys()]});
} 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 {inArray} from "drizzle-orm/sql/expressions/conditions";
import {Dao} from "../base/dao";
import {appLogger} from "../logging/logger";
import {buildExcludedSet} from "../util/utils";
import {StoredAttachment} from "../model/stored-attachment";
export class MessageDao extends Dao<StoredMessage> {
private tag: string = "MessageDao";
private readonly logger = appLogger.child("dao:messages");
override async getAll(): Promise<StoredMessage[]> {
await DatabaseManager.ready;
@@ -19,7 +20,7 @@ export class MessageDao extends Dao<StoredMessage> {
const now = Date.now();
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);
}
@@ -40,7 +41,7 @@ export class MessageDao extends Dao<StoredMessage> {
const now = Date.now();
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];
if (!m) return null;
@@ -63,7 +64,7 @@ export class MessageDao extends Dao<StoredMessage> {
const now = Date.now();
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);
}
@@ -83,7 +84,7 @@ export class MessageDao extends Dao<StoredMessage> {
const now = Date.now();
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;
}
+6 -5
View File
@@ -1,5 +1,6 @@
import {StoredUser} from "../model/stored-user";
import {Dao} from "../base/dao";
import {appLogger} from "../logging/logger";
import {DatabaseManager} from "./database-manager";
import {UserInsert, usersTable} from "./schema";
import {eq} from "drizzle-orm";
@@ -9,7 +10,7 @@ import {boolToInt, buildExcludedSet} from "../util/utils";
export class UserDao extends Dao<StoredUser> {
private tag: string = "UserDao";
private readonly logger = appLogger.child("dao:users");
override async getAll(): Promise<StoredUser[]> {
await DatabaseManager.ready;
@@ -19,7 +20,7 @@ export class UserDao extends Dao<StoredUser> {
const now = Date.now();
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);
}
@@ -37,7 +38,7 @@ export class UserDao extends Dao<StoredUser> {
const now = Date.now();
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];
if (!u) return null;
@@ -57,7 +58,7 @@ export class UserDao extends Dao<StoredUser> {
const now = Date.now();
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);
}
@@ -78,7 +79,7 @@ export class UserDao extends Dao<StoredUser> {
const now = Date.now();
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;
}
+14 -10
View File
@@ -1,4 +1,5 @@
import "dotenv/config";
import {appLogger} from "./logging/logger";
import {Environment} from "./common/environment";
import {BotCommand, TelegramBot, User} from "typescript-telegram-bot-api";
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 notesRootFile = path.join(notesDir, "index.md");
const logger = appLogger.child("main");
let isShuttingDown = false;
async function shutdown(signal: NodeJS.Signals) {
if (isShuttingDown) return;
isShuttingDown = true;
console.log(`Received ${signal}. Stopping bot polling...`);
logger.warn("shutdown.signal", {signal});
try {
await bot.stopPolling();
@@ -214,27 +217,30 @@ async function shutdown(signal: NodeJS.Signals) {
async function main() {
const start = Date.now();
console.log([
`TEST_ENVIRONMENT: ${Environment.TEST_ENVIRONMENT}`,
`IS_DOCKER: ${Environment.IS_DOCKER}`,
`DATA_PATH: ${Environment.DATA_PATH}`,
`DB_PATH: ${Environment.DB_PATH}`
].join("\n"));
logger.info("startup.config", {
testEnvironment: Environment.TEST_ENVIRONMENT,
isDocker: Environment.IS_DOCKER,
dataPath: Environment.DATA_PATH,
dbPath: Environment.DB_PATH,
});
const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir];
dirsToCheck.forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, {recursive: true});
logger.debug("startup.dir_created", {dir});
}
});
const notesRootFilePath = path.join(notesDir, "index.md");
if (!fs.existsSync(notesRootFilePath)) {
fs.writeFileSync(notesRootFilePath, "\n" + NOTES_HEADER);
logger.debug("startup.notes_root_created", {notesRootFilePath});
}
if (!(fs.readFileSync(notesRootFilePath).toString().includes(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)
@@ -245,12 +251,10 @@ async function main() {
// midnight.setDate(now.getDate() + 1);
// const diff = midnight.getTime() - now.getTime();
// console.log("Clearing up cache will be started in " + diff + "ms");
// clearUpFolderFromOldFiles(cacheDir);
// delay(diff).then(() => {
// setInterval(() => {
// console.log("Started clearing up cache");
// clearUpFolderFromOldFiles(cacheDir);
// }, 1000 * 60 * 60 * 24);
// });
@@ -278,7 +282,7 @@ async function main() {
const end = Date.now();
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) {
logError(error);
}
+10 -2
View File
@@ -1,6 +1,9 @@
import {logError} from "./utils";
import fs from "node:fs";
import path from "node:path";
import {appLogger} from "../logging/logger";
const logger = appLogger.child("files");
export function clearUpFolderFromOldFiles(folder: string, recursive = true) {
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) {
filenamesToDelete.forEach((filename) => {
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 {promisify} from "node:util";
import {appLogger} from "../logging/logger";
const execAsync = promisify(exec);
const logger = appLogger.child("shell-command-runner");
export type ShellCommandResult = {
stdout: string | null | undefined;
@@ -60,18 +62,17 @@ export class ShellCommandRunner {
maxBuffer: 64 * 1024,
});
if (stdout) {
console.log("COMMAND: ", command, "\n", "Output:", stdout);
logger.debug("command.stdout", {command, stdout});
}
if (stderr) {
console.error("COMMAND: ", command, "\n", "Error:", stderr);
logger.warn("command.stderr", {command, stderr});
}
return {stdout, stderr};
} catch (error: unknown) {
const err = error as Partial<Error & {code: unknown; stdout: string; stderr: string}>;
console.error("Error code:", err.code);
console.error("Stderr:", err.stderr);
logger.error("command.failed", {command, code: err.code, stderr: err.stderr, 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.
*/
import {appLogger} from "../logging/logger";
const logger = appLogger.child("telegram-api-queue");
export type TelegramChatId = number | string;
export type TelegramChatType = string;
@@ -345,6 +349,7 @@ export class TelegramApiQueue {
this.globalBucket = new SlidingWindowRateLimit(this.options.globalLimit);
this.editBucket = new SlidingWindowRateLimit(this.options.editLimit);
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 {
@@ -356,9 +361,18 @@ export class TelegramApiQueue {
}
enqueue<T>(task: TelegramApiTask<T>, options: TelegramApiQueueTaskOptions = {}): Promise<T> {
if (this.closed) return Promise.reject(createClosedError());
if (this.queue.length >= this.options.maxQueueSize) return Promise.reject(createQueueOverflowError(this.options.maxQueueSize));
if (options.signal?.aborted) return Promise.reject(createAbortError());
if (this.closed) {
logger.warn("enqueue.rejected.closed", {method: options.method, chatId: options.chatId});
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) => {
const entry: QueueEntry<T> = {
@@ -376,6 +390,7 @@ export class TelegramApiQueue {
this.attachAbortHandler(entry);
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();
});
}
@@ -390,12 +405,14 @@ export class TelegramApiQueue {
close(reason: unknown = createClosedError()): void {
this.closed = true;
logger.warn("closed", {queued: this.queue.length, running: this.running, reason});
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const queued = this.queue;
logger.debug("close.cancel_queued", {queued: queued.length});
this.queue = [];
for (const entry of queued) {
this.cleanupAbortHandler(entry);
@@ -409,6 +426,7 @@ export class TelegramApiQueue {
clear(reason: unknown = new Error("Telegram API queue was cleared")): void {
const queued = this.queue;
logger.warn("cleared", {queued: queued.length, running: this.running, reason});
this.queue = [];
for (const entry of queued) {
this.cleanupAbortHandler(entry);
@@ -435,6 +453,7 @@ export class TelegramApiQueue {
this.cleanupAbortHandler(entry);
entry.state = "cancelled";
logger.debug("entry.cancelled", {taskId: entry.id, method: entry.options.method, chatId: entry.options.chatId});
entry.reject(createAbortError());
this.resolveIdleIfNeeded();
}
@@ -456,6 +475,7 @@ export class TelegramApiQueue {
}
if (selection.delayMs > 0) {
logger.trace("pump.delayed", {delayMs: selection.delayMs, queued: this.queue.length, running: this.running});
this.schedule(selection.delayMs);
return;
}
@@ -497,6 +517,7 @@ export class TelegramApiQueue {
this.cleanupAbortHandler(entry);
this.recordStart(entry, Date.now());
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);
}
@@ -509,6 +530,7 @@ export class TelegramApiQueue {
signal: entry.options.signal,
});
entry.state = "settled";
logger.trace("entry.settled", {taskId: entry.id, method: entry.options.method, chatId: entry.options.chatId, attempt: entry.attempt});
entry.resolve(result);
} catch (error) {
const retry = this.getRetryDecision(error, entry);
@@ -523,6 +545,7 @@ export class TelegramApiQueue {
} else {
this.attachAbortHandler(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?.({
taskId: entry.id,
method: entry.options.method,
@@ -535,6 +558,7 @@ export class TelegramApiQueue {
}
} else {
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);
}
} finally {
+33 -15
View File
@@ -1,5 +1,5 @@
import * as si from "systeminformation";
import {redactLogValue} from "../ai/ai-logger";
import {appLogger} from "../logging/logger";
import {Command} from "../base/command";
import {CallbackCommand} from "../base/callback-command";
import {
@@ -49,6 +49,9 @@ import {ShellCommandResult, ShellCommandRunner} from "./shell-command-runner";
const imageProcessingSemaphore = new AsyncSemaphore(2);
const fileWriteLocks = new KeyedAsyncLock();
const logger = appLogger.child("utils");
const requirementLogger = appLogger.child("requirements");
const messageLogger = appLogger.child("messages");
export const ignore = () => {
};
@@ -66,7 +69,7 @@ export const ignoreIfMarkupFailed = (e: Error | TelegramError) => {
};
export const logError = (e: Error | TelegramError | string | unknown) => {
console.error(redactLogValue(e));
appLogger.error("error", {error: e});
};
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.ADMIN_IDS.has(chatId) &&
!Environment.ADMIN_IDS.has(fromId)) {
console.log(`${title}: chatId whitelist ignored.`);
requirementLogger.debug("rejected.chat_whitelist", {title, chatId, fromId});
return false;
}
@@ -172,19 +175,19 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m
};
if (reqs.isRequiresBotCreator() && fromId !== Environment.CREATOR_ID) {
console.log(`${title}: creatorId is bad`);
requirementLogger.debug("rejected.creator", {title, fromId});
await notifyUser(Environment.notBotCreatorText);
return false;
}
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);
return false;
}
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);
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});
if (!isMemberAdmin(member)) {
console.log(`${title}: chatAdminId is bad`);
requirementLogger.debug("rejected.chat_admin", {title, chatId, fromId});
await notifyUser(Environment.notChatAdministratorText);
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});
if (!isMemberAdmin(member)) {
console.log(`${title}: botChatAdminId is bad`);
requirementLogger.debug("rejected.bot_chat_admin", {title, chatId});
await notifyUser(Environment.botNotChatAdministratorText);
return false;
}
}
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);
return false;
}
@@ -242,7 +245,7 @@ export async function checkRequirements(cmd: Command | CallbackCommand | null, m
}
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);
return false;
}
@@ -2090,19 +2093,23 @@ export function photoPathByUniqueId(uniqueId: string): string {
}
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> {
// 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> {
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;
Environment.reloadRuntimeConfigIfChanged();
@@ -2115,6 +2122,7 @@ export async function processNewMessage(msg: Message, isGuest?: boolean): Promis
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];
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/")
|| !!msg.video_note;
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 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) {
messageLogger.debug("message.skipped.not_addressed", {chatId: msg.chat.id, messageId: msg.message_id});
return;
}
}
const provider = await resolveEffectiveAiProviderForUser(from.id);
messageLogger.info("ai.dispatch", {chatId: msg.chat.id, messageId: msg.message_id, fromId: from.id, provider});
void runUnifiedAi({
provider: provider,
msg: msg,