feat: separate Ollama text/image/think models + add YouTube downloader

Support OLLAMA_IMAGE_MODEL and new OLLAMA_THINK_MODEL (both default to OLLAMA_MODEL)

Add /ollamathink command; validate thinking capability; disable image-analysis flow in think mode

Make /ollama-get-model show Text/Image/Think blocks only when models differ

Add /ytdl (/youtube) command + auto-detect YouTube URLs in messages

Cache downloaded videos to data/video and schedule daily cleanup

Move photo storage from data/temp to data/photo; improve env boolean parsing via ifTrue

Update deps (youtubei.js, puppeteer*) and TS config (allowJs, allowSyntheticDefaultImports)
This commit is contained in:
2026-01-31 19:36:11 +03:00
parent 23052fae0f
commit 810151263d
14 changed files with 1921 additions and 66 deletions
+1
View File
@@ -81,6 +81,7 @@ export class MistralChat extends ChatCommand {
const stream = await mistralAi.chat.stream({
model: Environment.MISTRAL_MODEL,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
messages: chatMessages as any
});
+26 -7
View File
@@ -16,7 +16,7 @@ import {OllamaCancel} from "../callback_commands/ollama-cancel";
import {OllamaGetModel} from "./ollama-get-model";
export class OllamaChat extends ChatCommand {
command = "ollama";
command = ["ollama", "ollamathink"];
argsMode = "required" as const;
title = "/ollama";
@@ -24,10 +24,10 @@ export class OllamaChat extends ChatCommand {
async execute(msg: Message, match?: RegExpExecArray | null): Promise<void> {
console.log("match", match);
return this.executeOllama(msg, match?.[3]);
return this.executeOllama(msg, match?.[3], match?.[1]?.toLowerCase()?.startsWith("ollamathink"));
}
async executeOllama(msg: Message, text: string): Promise<void> {
async executeOllama(msg: Message, text: string, think: boolean = false): Promise<void> {
if (!text || text.trim().length === 0) return;
const chatId = msg.chat.id;
@@ -55,7 +55,7 @@ export class OllamaChat extends ChatCommand {
return total + (curr.images?.length ?? 0);
}, 0);
if (imagesCount) {
if (!think && imagesCount) {
try {
const modelInfo = await chatCommands.find(c => c instanceof OllamaGetModel).loadImageModelInfo();
if (modelInfo) {
@@ -73,20 +73,38 @@ export class OllamaChat extends ChatCommand {
}
}
if (think) {
try {
const modelInfo = await chatCommands.find(c => c instanceof OllamaGetModel).loadThinkModelInfo();
if (modelInfo) {
const caps = modelInfo.capabilities || [];
if (!caps.includes("thinking")) {
await replyToMessage({
message: msg,
text: "Моя текущая модель не умеет размышлять 🥹"
});
return;
}
}
} catch (e) {
logError(e);
}
}
const uuid = crypto.randomUUID();
const cancelMarkup = {inline_keyboard: [[Cancel.withData(new OllamaCancel().data + " " + uuid).asButton()]]};
waitMessage = await replyToMessage({
message: msg,
text: imagesCount ?
text: (!think && imagesCount) ?
imagesCount > 1 ? Environment.analyzingPicturesText : Environment.analyzingPictureText
: Environment.waitText
});
const stream = await ollama.chat({
model: imagesCount ? Environment.OLLAMA_IMAGE_MODEL : Environment.OLLAMA_MODEL,
model: think ? Environment.OLLAMA_THINK_MODEL : imagesCount ? Environment.OLLAMA_IMAGE_MODEL : Environment.OLLAMA_MODEL,
stream: true,
think: false,
think: think,
messages: chatMessages,
});
@@ -153,6 +171,7 @@ export class OllamaChat extends ChatCommand {
message_id: waitMessage.message_id,
text: "🤔 Размышляю...",
parse_mode: "Markdown",
reply_markup: cancelMarkup
}).catch(logError);
}
+43 -5
View File
@@ -11,14 +11,48 @@ export class OllamaGetModel extends ChatCommand {
async execute(msg: Message): Promise<void> {
try {
let modelInfo = await this.loadModelInfo();
const modelText = "```Text\n" + this.getModelText(Environment.OLLAMA_MODEL, modelInfo) + "```";
modelInfo = await this.loadImageModelInfo();
const imageModelText = "```Image\n" + this.getModelText(Environment.OLLAMA_IMAGE_MODEL, modelInfo) + "```";
const model = Environment.OLLAMA_MODEL;
const imageModel = Environment.OLLAMA_IMAGE_MODEL;
const thinkModel = Environment.OLLAMA_THINK_MODEL;
const promises: (Promise<ShowResponse | null> | null)[] = [this.loadModelInfo()];
if (imageModel && imageModel !== model) {
promises.push(this.loadImageModelInfo());
} else {
promises.push(null);
}
if (thinkModel && thinkModel !== model) {
promises.push(this.loadThinkModelInfo());
} else {
promises.push(null);
}
const infos = await Promise.all(promises);
let modelInfo = infos[0];
const modelText = "```Text\n" + this.getModelText(model, modelInfo) + "```";
modelInfo = infos[1];
const imageModelText = modelInfo ?
"```Image\n" + this.getModelText(imageModel, modelInfo) + "```" : null;
modelInfo = infos[2];
const thinkModelText = modelInfo ?
"```Think\n" + this.getModelText(thinkModel, modelInfo) + "```" : null;
const modelInfos = [modelText];
if (imageModelText) {
modelInfos.push(imageModelText);
}
if (thinkModelText) {
modelInfos.push(thinkModelText);
}
await replyToMessage({
message: msg,
text: modelText + "\n\n" + imageModelText,
text: modelInfos.join("\n\n"),
parse_mode: "Markdown"
}).catch(logError);
@@ -44,4 +78,8 @@ export class OllamaGetModel extends ChatCommand {
async loadImageModelInfo(): Promise<ShowResponse | null> {
return ollama.show({model: Environment.OLLAMA_IMAGE_MODEL});
}
async loadThinkModelInfo(): Promise<ShowResponse | null> {
return ollama.show({model: Environment.OLLAMA_THINK_MODEL});
}
}
+54
View File
@@ -0,0 +1,54 @@
import {ChatCommand} from "../base/chat-command";
import {Message} from "typescript-telegram-bot-api";
import {logError, replyToMessage} from "../util/utils";
import {bot} from "../index";
import {downloadVideoFromYouTube} from "../util/ytdl";
export class YouTubeDownload extends ChatCommand {
command = ["ytdl", "youtube"];
argsMode = "required" as const;
async execute(msg: Message, match?: RegExpExecArray): Promise<void> {
const url = match?.[3];
return this.downloadYouTubeVideo(msg, url);
}
async downloadYouTubeVideo(msg: Message, url: string): Promise<void> {
let waitMessage: Message | null = null;
try {
waitMessage = await replyToMessage({message: msg, text: "⏳ Секунду..."});
const {time, exists, buffer} = await downloadVideoFromYouTube(url);
if (buffer) {
const start = Date.now();
waitMessage = await bot.editMessageMedia({
chat_id: msg.chat.id,
message_id: waitMessage.message_id,
media: {
type: "video",
media: buffer
}
}) as Message;
const diff = Date.now() - start;
waitMessage = await bot.editMessageCaption({
chat_id: msg.chat.id,
message_id: waitMessage.message_id,
caption: `✅ [Видео](${url})` + (exists ? " загружено из кэша" : " успешно скачано") + " за " + (time + diff) + "мс",
parse_mode: "MarkdownV2"
}) as Message;
}
} catch (e) {
logError(e);
if (waitMessage && "text" in waitMessage) {
await bot.editMessageText({
chat_id: msg.chat.id,
message_id: waitMessage.message_id,
text: `⚠️ Произошла ошибка.\n${e}`,
});
}
}
}
}
+8 -5
View File
@@ -1,6 +1,7 @@
import path from "node:path";
import {saveData} from "../db/database";
import {Answers} from "../model/answers";
import {ifTrue} from "../util/utils";
export class Environment {
static BOT_TOKEN: string;
@@ -28,6 +29,7 @@ export class Environment {
static OLLAMA_ADDRESS?: string;
static OLLAMA_MODEL?: string;
static OLLAMA_IMAGE_MODEL?: string;
static OLLAMA_THINK_MODEL?: string;
static OLLAMA_API_KEY?: string;
static GEMINI_API_KEY?: string;
@@ -45,17 +47,17 @@ export class Environment {
static load() {
Environment.BOT_TOKEN = process.env.BOT_TOKEN;
Environment.TEST_ENVIRONMENT = process.env.TEST_ENVIRONMENT === "true";
Environment.TEST_ENVIRONMENT = ifTrue(process.env.TEST_ENVIRONMENT);
Environment.CHAT_IDS_WHITELIST = new Set(process.env.CHAT_IDS_WHITELIST?.split(",")?.map(e => parseInt(e.trim(), 10)) || []);
Environment.BOT_PREFIX = process.env.BOT_PREFIX || "";
Environment.CREATOR_ID = parseInt(process.env.CREATOR_ID || "");
Environment.IS_DOCKER = process.env.IS_DOCKER == "true";
Environment.IS_DOCKER = ifTrue(process.env.IS_DOCKER);
Environment.DATA_PATH = Environment.IS_DOCKER ? "/" + path.join("config", "data") : "data";
Environment.DB_PATH = "file:" + path.join(Environment.DATA_PATH, Environment.DB_FILE_NAME);
Environment.ONLY_FOR_CREATOR_MODE = process.env.ONLY_FOR_CREATOR_MODE == "true";
Environment.ONLY_FOR_CREATOR_MODE = ifTrue(process.env.ONLY_FOR_CREATOR_MODE);
Environment.USE_NAMES_IN_PROMPT = process.env.USE_NAMES_IN_PROMPT == "true";
Environment.USE_NAMES_IN_PROMPT = ifTrue(process.env.USE_NAMES_IN_PROMPT);
Environment.MAX_PHOTO_SIZE = Number(process.env.MAX_PHOTO_SIZE || "1280");
@@ -63,7 +65,8 @@ export class Environment {
Environment.OLLAMA_ADDRESS = process.env.OLLAMA_ADDRESS;
Environment.OLLAMA_MODEL = process.env.OLLAMA_MODEL || "gemma3:4b";
Environment.OLLAMA_IMAGE_MODEL = process.env.OLLAMA_IMAGE_MODEL || "gemma3:4b";
Environment.OLLAMA_IMAGE_MODEL = process.env.OLLAMA_IMAGE_MODEL || Environment.OLLAMA_MODEL;
Environment.OLLAMA_THINK_MODEL = process.env.OLLAMA_THINK_MODEL || Environment.OLLAMA_MODEL;
Environment.OLLAMA_API_KEY = process.env.OLLAMA_API_KEY;
Environment.GEMINI_API_KEY = process.env.GEMINI_API_KEY;
+36 -1
View File
@@ -3,8 +3,10 @@ import {Environment} from "./common/environment";
import {InlineQueryResult, TelegramBot, User} from "typescript-telegram-bot-api";
import {ChatCommand} from "./base/chat-command";
import {
delay,
extractTextMessage,
findAndExecuteCallbackCommand,
ignore,
initSystemSpecs,
logError,
processNewMessage
@@ -66,6 +68,11 @@ import {GeminiGetModel} from "./commands/gemini-get-model";
import {GeminiSetModel} from "./commands/gemini-set-model";
import {Debug} from "./commands/debug";
import {GeminiGenerateImage} from "./commands/gemini-generate-image";
import {YouTubeDownload} from "./commands/youtube-download";
import fs from "node:fs";
import path from "node:path";
import {setInterval} from "node:timers";
import {clearUpVideoFolder} from "./util/files";
process.setUncaughtExceptionCaptureCallback(logError);
@@ -148,6 +155,8 @@ export const chatCommands: ChatCommand[] = [
new Shutdown(),
new Leave(),
new YouTubeDownload()
];
export const callbackCommands: CallbackCommand[] = [
@@ -187,7 +196,12 @@ if (Environment.MISTRAL_API_KEY) {
);
}
export const photoDir = path.join(Environment.DATA_PATH, "photo");
export const videoDir = path.join(Environment.DATA_PATH, "video");
async function main() {
const start = Date.now();
console.log(
`TEST_ENVIRONMENT: ${Environment.TEST_ENVIRONMENT}\n` +
`DATA_PATH: ${Environment.DATA_PATH}\n` +
@@ -195,6 +209,25 @@ async function main() {
`ONLY_FOR_CREATOR: ${Environment.ONLY_FOR_CREATOR_MODE}`
);
fs.mkdir(photoDir, ignore);
fs.mkdir(videoDir, ignore);
const now = new Date();
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
midnight.setDate(now.getDate() + 1);
const diff = midnight.getTime() - now.getTime();
console.log("Clearing up videos will be started in " + diff + "ms");
delay(diff).then(() => {
setInterval(() => {
console.log("Started clearing up videos");
clearUpVideoFolder();
}, 1000 * 60 * 60 * 24);
});
const commands = chatCommands.filter(cmd => {
return cmd.title && cmd.title.startsWith("/") && cmd.title.split(" ").length === 1 && cmd.description;
}).map(cmd => {
@@ -216,7 +249,9 @@ async function main() {
await UserStore.put(botUser);
await bot.startPolling();
console.log("Bot started!");
const end = Date.now();
const diff = Math.abs(end - start);
console.log(`Bot started in ${diff}ms!`);
} catch (error) {
logError(error);
}
+1
View File
@@ -1,5 +1,6 @@
export type OllamaRequest = {
uuid: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stream: any;
done: boolean;
fromId: number;
+41
View File
@@ -0,0 +1,41 @@
import {logError} from "./utils";
import fs from "node:fs";
import {videoDir} from "../index";
import path from "node:path";
export function clearUpVideoFolder() {
fs.readdir(videoDir, (err, files) => {
if (err) {
logError(err);
return;
}
const filenamesToDelete: string[] = [];
files.forEach((filename, index) => {
fs.stat(path.join(videoDir, filename), (err, stats) => {
if (err) {
logError(err);
} else {
const then = stats.mtime.getTime() / 1000;
const now = Date.now() / 1000;
const diff = Math.abs(now - then);
const moreThanOneDay = diff >= 60 * 60 * 24;
if (moreThanOneDay) {
filenamesToDelete.push(filename);
}
if (index === files.length - 1) {
console.log("filenamesToDelete", filenamesToDelete);
if (filenamesToDelete.length) {
filenamesToDelete.forEach((filename) => {
const fullPath = path.join(videoDir, filename);
fs.rm(fullPath, logError);
});
}
}
}
});
});
});
}
+50 -17
View File
@@ -27,6 +27,8 @@ import {MessageStore} from "../common/message-store";
import {SystemInfo} from "../commands/system-info";
import {PrefixResponse} from "../commands/prefix-response";
import {OllamaChat} from "../commands/ollama-chat";
import {getYouTubeVideoId} from "./ytdl";
import {YouTubeDownload} from "../commands/youtube-download";
export const ignore = () => {
};
@@ -469,10 +471,14 @@ export function extractTextMessage(msg: Message | StoredMessage | string): strin
export function cutPrefixes(msg: Message | StoredMessage | string): string {
const prefixes = [
Environment.BOT_PREFIX,
`/ollamathink@${botUser.username}`,
"/ollamathink",
`/ollama@${botUser.username}`,
"/ollama",
`/gemini@${botUser.username}`,
"/gemini",
`/mistral@${botUser.username}`,
"/mistral"
"/mistral",
];
const text = extractTextMessage(msg);
@@ -510,7 +516,7 @@ export async function loadImagesIfExists(msg: Message | StoredMessage): Promise<
const maxSize = await mapPhotoSizeToMax(getPhotoMaxSize(msg.photo));
if (maxSize) {
const imagePath = path.join(Environment.DATA_PATH, "temp");
const imagePath = path.join(Environment.DATA_PATH, "photo");
if (!fs.existsSync(imagePath)) {
fs.mkdirSync(imagePath);
}
@@ -539,7 +545,7 @@ export async function loadImagesIfExists(msg: Message | StoredMessage): Promise<
export async function loadImagesFromFileIds(sizes: PhotoSize[]): Promise<string[] | null> {
if (!sizes?.length) return null;
const dataPath = path.join(Environment.DATA_PATH, "temp");
const dataPath = path.join(Environment.DATA_PATH, "photo");
if (!fs.existsSync(dataPath)) {
fs.mkdirSync(dataPath);
}
@@ -988,20 +994,25 @@ export async function mapPhotoSizeToMax(size: PhotoSize): Promise<PhotoMaxSize |
};
}
export async function imageToBase64(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
fs.readFile(filePath, (err, data) => {
if (err) {
return reject(err);
}
const base64Image = Buffer.from(data).toString("base64");
const dataUrl = `data:image/jpeg;base64,${base64Image}`;
resolve(dataUrl);
});
});
export async function imageToBase64(filePath: string, withMimeType: boolean = false): Promise<string | null> {
if (!fs.existsSync(filePath)) return null;
try {
const file = fs.readFileSync(filePath);
const base64 = Buffer.from(file).toString("base64");
if (withMimeType) {
return `data:image/jpeg;base64,${base64}`;
}
return base64;
} catch (e) {
logError(e);
return null;
}
}
export function ifTrue(exp?: never): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function ifTrue(exp?: any): boolean {
if (!exp) return false;
return ["true", "t", "y", 1, "1"].includes(exp);
@@ -1093,12 +1104,34 @@ export async function processNewMessage(msg: Message) {
return;
}
const textToCheck = startsWithPrefix ? messageWithoutPrefix : cmdText;
if (msg.entities) {
const urlEntities = msg.entities.filter(e => e.type === "url");
if (urlEntities.length) {
for (const e of urlEntities) {
const url = msg.text.substring(e.offset, e.offset + e.length);
// TODO: 31/01/2026, Danil Nikolaev: implement proper checking
try {
getYouTubeVideoId(url);
const yt = chatCommands.find(e => e instanceof YouTubeDownload);
if (await checkRequirements(yt, msg)) {
await yt.downloadYouTubeVideo(msg, url);
}
return;
} catch (e) {
logError(e);
}
}
}
}
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);
await chat.executeOllama(msg, textToCheck);
}
}
@@ -1121,5 +1154,5 @@ async function processAlbum(groupId: string): Promise<string[]> {
}
export function photoPathByUniqueId(uniqueId: string): string {
return path.join(Environment.DATA_PATH, "temp", uniqueId + ".jpg");
return path.join(Environment.DATA_PATH, "photo", uniqueId + ".jpg");
}
+93
View File
@@ -0,0 +1,93 @@
import Innertube, {Platform, Types, Utils} from "youtubei.js";
import fs, {createWriteStream} from "node:fs";
import path from "node:path";
import {Environment} from "../common/environment";
export function getYouTubeVideoId(url: string): string {
const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?|shorts)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i;
const match = url.match(regex);
if (!match || !match[1]) throw new Error("Invalid YouTube or Shorts URL");
return match[1];
}
export async function downloadVideoFromYouTube(url: string, targetQuality: string = "720p"): Promise<{
time: number,
exists?: boolean,
buffer: Buffer | null
}> {
const start = Date.now();
let buffer: Buffer | null = null;
try {
const videoId = getYouTubeVideoId(url);
const videoFolder = path.join(Environment.DATA_PATH, "video");
if (!fs.existsSync(videoFolder)) {
fs.mkdirSync(videoFolder);
}
const filePath = path.join(videoFolder, `${videoId}.mp4`);
if (fs.existsSync(filePath)) {
const buffer = Buffer.from(fs.readFileSync(filePath));
return {
time: Date.now() - start,
exists: true,
buffer: buffer
};
}
Platform.shim.eval = async (data: Types.BuildScriptResult, env: Record<string, Types.VMPrimative>) => {
const properties = [];
if (env.n) properties.push(`n: exportedVars.nFunction("${env.n}")`);
if (env.sig) properties.push(`sig: exportedVars.sigFunction("${env.sig}")`);
const code = `${data.output}\nreturn { ${properties.join(", ")} }`;
return new Function(code)();
};
const yt = await Innertube.create({
generate_session_locally: true,
retrieve_player: true
});
const info = await yt.getInfo(videoId);
console.log(`Fetching metadata for: ${videoId}...`);
const format = info.streaming_data?.formats.find(f => f.quality_label === targetQuality)
|| info.streaming_data?.adaptive_formats.find(f => f.quality_label === targetQuality);
if (!format) {
console.log(`Quality ${targetQuality} not found. Falling back to best available.`);
}
const stream = await yt.download(videoId, {
type: "video+audio",
quality: "best",
format: "mp4"
});
const file = createWriteStream(filePath);
console.log("Downloading...");
for await (const chunk of Utils.streamToIterable(stream)) {
file.write(chunk);
}
file.end();
buffer = fs.readFileSync(filePath);
console.log(`✅ Saved to ${videoId}.mp4`);
} catch (error) {
console.error("❌ Download failed:", error instanceof Error ? error.message : error);
throw error;
}
const end = Date.now();
const diff = end - start;
console.log(`Video downloaded. URL: ${url}\ntook ${diff}ms`);
return {
time: diff,
buffer: buffer,
};
}