refactor(bot): centralize runtime state; support albums + safer vision handling

- make MessageStore.put() return StoredMessage and allow collectReplyChainText() to work with StoredMessage
- move muted users + answers loading into Environment (add Answers model + GEMINI_IMAGE_MODEL)
- extract message handling into processNewMessage() and add media-group (album) caching/downloading by unique_file_id
- for Ollama: check model capabilities before sending images; use replyToMessage helper consistently
- add /geminiGenImage command stub for Gemini image generation
This commit is contained in:
2026-01-29 20:01:30 +03:00
parent 5f4405c9ee
commit b74e0a0f3e
16 changed files with 312 additions and 148 deletions
+147 -6
View File
@@ -12,7 +12,7 @@ import {
} from "typescript-telegram-bot-api";
import {Environment} from "../common/environment";
import {TelegramError} from "typescript-telegram-bot-api/dist/errors";
import {bot, botUser, messageDao} from "../index";
import {bot, botUser, chatCommands, messageDao} from "../index";
import os from "os";
import axios from "axios";
import {MessagePart} from "../common/message-part";
@@ -25,6 +25,8 @@ import fs from "node:fs";
import path from "node:path";
import {MessageStore} from "../common/message-store";
import {SystemInfo} from "../commands/system-info";
import {PrefixResponse} from "../commands/prefix-response";
import {OllamaChat} from "../commands/ollama-chat";
export const ignore = () => {
};
@@ -525,7 +527,35 @@ export async function loadImagesIfExists(msg: Message | StoredMessage): Promise<
return imageFilePaths;
}
export async function collectReplyChainText(triggerMsg: Message, limit: number = 40, includeTrigger = true, cutPrefix: boolean = true): Promise<MessagePart[]> {
export async function loadImagesFromFileIds(maxSizes: PhotoMaxSize[]): Promise<string[] | null> {
if (!maxSizes?.length) return null;
const dataPath = path.join(Environment.DATA_PATH, "temp");
if (!fs.existsSync(dataPath)) {
fs.mkdirSync(dataPath);
}
const imagePromises = maxSizes.map((size) => {
return axios.get<ArrayBuffer>(size.url, {responseType: "arraybuffer"});
});
const responses = await Promise.all(imagePromises);
const paths = responses.map((res, index) => {
try {
const imageFilePath = path.join(dataPath, maxSizes[index].unique_file_id + ".jpg");
const src = Buffer.from(res.data);
fs.writeFileSync(imageFilePath, src);
return imageFilePath;
} catch (e) {
logError(e);
return null;
}
});
return paths.filter(p => p);
}
export async function collectReplyChainText(triggerMsg: Message | StoredMessage, limit: number = 40, includeTrigger = true, cutPrefix: boolean = true): Promise<MessagePart[]> {
const parts: MessagePart[] = [];
const pushPart = async (msg: Message | StoredMessage, textRequired: boolean = false) => {
@@ -548,19 +578,21 @@ export async function collectReplyChainText(triggerMsg: Message, limit: number =
});
};
const chatId = triggerMsg.chat.id as number;
const chatId = isStoredMessage(triggerMsg) ? triggerMsg.chatId as number : triggerMsg.chat.id;
if (includeTrigger) {
await pushPart(triggerMsg);
}
const first = triggerMsg.reply_to_message;
const first = isStoredMessage(triggerMsg) ?
(await MessageStore.get(chatId, triggerMsg.replyToMessageId)) :
triggerMsg.reply_to_message;
if (!first) {
return parts;
}
await pushPart(first, false);
let curId = first.message_id;
let curId = isStoredMessage(first) ? first.id : first.message_id;
while (parts.length < limit) {
const cur = await messageDao.getById({chatId: chatId, id: curId});
@@ -898,7 +930,7 @@ export function getRuntimeInfo(): RuntimeInfo {
return {runtime: "unknown", version: String((process as any).version ?? "")};
}
export type PhotoMaxSize = { width: number, height: number, url: string; unique_file_id: string; };
export type PhotoMaxSize = { width: number, height: number, url: string; file_id: string; unique_file_id: string; };
export async function getPhotoMaxSize(photos: PhotoSize[], target: number = Environment.MAX_PHOTO_SIZE): Promise<PhotoMaxSize | null> {
if (!photos) return null;
@@ -927,6 +959,7 @@ export async function mapPhotoSizeToMax(size: PhotoSize): Promise<PhotoMaxSize |
width: size.width,
height: size.height,
url: await getFileUrl(size.file_id),
file_id: size.file_id,
unique_file_id: size.file_unique_id
};
}
@@ -952,4 +985,112 @@ export function ifTrue(exp?: never): boolean {
export function boolToEmoji(bool: boolean): string {
return bool ? "✅" : "❌";
}
export const albumCache = new Map<string, { messages: Message[], timer: NodeJS.Timeout }>();
export async function processNewMessage(msg: Message) {
console.log("message", msg);
let storedMsg: StoredMessage | null = null;
Promise.all([
MessageStore.put(msg).then(r => {
storedMsg = r;
console.log("storedMsg", storedMsg);
}),
UserStore.put(msg.from)
]
).catch(logError);
if ((msg.new_chat_members?.length || 0 > 0)) {
await bot.sendMessage({chat_id: msg.chat.id, text: randomValue(Environment.ANSWERS.invite)}).catch(logError);
return;
}
if (msg.left_chat_member && msg.left_chat_member.id !== botUser.id) {
await bot.sendMessage({chat_id: msg.chat.id, text: randomValue(Environment.ANSWERS.kick)}).catch(logError);
return;
}
if (Environment.MUTED_IDS.has(msg.from.id)) return;
if (msg.forward_origin) return;
const groupId = msg.media_group_id;
if (groupId) {
await new Promise<true>(resolve => {
if (!albumCache.has(groupId)) {
albumCache.set(groupId, {
messages: [msg],
timer: setTimeout(async () => {
const photos = await processAlbum(groupId);
console.log("processedAlbum", photos);
storedMsg.photoMaxSizeFilePath = photos;
await MessageStore.put(storedMsg).catch(logError);
resolve(true);
}, 1000)
});
} else {
const entry = albumCache.get(groupId);
entry.messages.push(msg);
}
});
}
const cmdText = msg.text || msg.caption || "";
const then = Date.now();
const cmd = searchChatCommand(chatCommands, cmdText);
const executed = await executeChatCommand(cmd, msg, cmdText);
const now = Date.now();
const diff = now - then;
console.log("diff", diff);
if (executed || !cmdText) return;
const startsWithPrefix = cmdText.toLowerCase().startsWith(Environment.BOT_PREFIX.toLowerCase());
const messageWithoutPrefix = cmdText.substring(Environment.BOT_PREFIX.length).trim();
if (startsWithPrefix && messageWithoutPrefix.length === 0) {
const prefixResponse = new PrefixResponse();
if (await checkRequirements(prefixResponse, msg)) {
await prefixResponse.execute(msg);
}
return;
}
if (!startsWithPrefix && msg.chat.type !== "private") return;
if (msg.chat.type === "private" && !Environment.ADMIN_IDS.has(msg.chat.id)) return;
const chat = chatCommands.find(e => e instanceof OllamaChat);
if (await checkRequirements(chat, msg)) {
await chat.executeOllama(msg, startsWithPrefix ? messageWithoutPrefix : cmdText);
}
}
async function processAlbum(groupId: string): Promise<string[]> {
const entry = albumCache.get(groupId);
if (!entry) return;
const allPhotos = entry.messages
.filter(m => m.photo)
.map(m => m.photo);
const allPhotoMaxSizes = await Promise.all(allPhotos.map(photo => getPhotoMaxSize(photo)));
const ids = allPhotoMaxSizes.map(p => p.unique_file_id);
await loadImagesFromFileIds(allPhotoMaxSizes);
console.log(`Received album ${groupId} with ${ids.length} photos.`);
console.log("File IDs:", ids);
albumCache.delete(groupId);
return ids;
}
export function photoPathByUniqueId(uniqueId: string): string {
return path.join(Environment.DATA_PATH, "temp", uniqueId + ".jpg");
}