From a1bcd4562a5ea448320292569521c25afb6aa117 Mon Sep 17 00:00:00 2001 From: Aidan Date: Sun, 4 May 2025 00:41:37 -0400 Subject: [PATCH 01/12] docs: add ai documentation --- README.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8fa5b60..a0e4aab 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Kowalski is a a simple Telegram bot made in Node.js. - FFmpeg (only for the `/yt` command) - Docker and Docker Compose (only required for Docker setup) +_AI features require a higher-end system with a CPU/GPU_ + ## Running locally (non-Docker setup) First, clone the repo with Git: @@ -55,9 +57,23 @@ You can also run Kowalski using Docker, which simplifies the setup process. Make ### Using Docker Compose -1. **Make sure to setup your `.env` file first!** +1. **Copy compose file** -2. **Run the container** + _Without AI (Ollama)_ + + ```bash + mv docker-compose.yml.example docker-compose.yml + ``` + + _With AI (Ollama)_ + + ```bash + mv docker-compose.yml.ai.example docker-compose.yml + ``` + +2. **Make sure to setup your `.env` file first!** + +3. **Run the container** ```bash docker compose up -d @@ -81,6 +97,9 @@ If you prefer to use Docker directly, you can use these instructions instead. docker run -d --name kowalski --restart unless-stopped -v $(pwd)/.env:/usr/src/app/.env:ro kowalski ``` +> [!NOTE] +> You must setup Ollama on your own if you would like to use AI features. + ## .env Functions > [!IMPORTANT] @@ -90,6 +109,7 @@ If you prefer to use Docker directly, you can use these instructions instead. - **botPrivacy**: Put the link to your bot privacy policy. - **maxRetries**: Maximum number of retries for a failing command on Kowalski. Default is 5. If the limit is hit, the bot will crash past this number. - **botToken**: Put your bot token that you created at [@BotFather](https://t.me/botfather). +- **ollamaApi** (optional): Ollama API endpoint for various AI features, will be disabled if not set - **botAdmins**: Put the ID of the people responsible for managing the bot. They can use some administrative + exclusive commands on any group. - **lastKey**: Last.fm API key, for use on `lastfm.js` functions, like see who is listening to what song and etc. - **weatherKey**: Weather.com API key, used for the `/weather` command. @@ -106,6 +126,12 @@ If you prefer to use Docker directly, you can use these instructions instead. chmod +x src/plugins/yt-dlp/yt-dlp ``` +### AI + +**Q:** How can I disable AI features? + +**A:** AI features are disabled by default, unless you have set `ollamaApi` in your `.env` file. Please remove or comment out this line to disable all AI functionality. + ## Contributors -- 2.47.2 From 38e6fcc1b336296db07af93f4a6d10efea9f90dd Mon Sep 17 00:00:00 2001 From: Aidan Date: Sun, 4 May 2025 00:42:15 -0400 Subject: [PATCH 02/12] docker: update docker files for ai/regular versions, lint --- .dockerignore | 2 +- docker-compose.yml.ai.example | 15 +++++++++++++++ docker-compose.yml.example | 9 +++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 docker-compose.yml.ai.example create mode 100644 docker-compose.yml.example diff --git a/.dockerignore b/.dockerignore index 33e390a..a082d1a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,4 @@ npm-debug.log .gitignore .env *.md -!README.md \ No newline at end of file +!README.md \ No newline at end of file diff --git a/docker-compose.yml.ai.example b/docker-compose.yml.ai.example new file mode 100644 index 0000000..2c516f7 --- /dev/null +++ b/docker-compose.yml.ai.example @@ -0,0 +1,15 @@ +services: + kowalski: + build: . + container_name: kowalski + restart: unless-stopped + volumes: + - ./.env:/usr/src/app/.env:ro + environment: + - NODE_ENV=production + ollama: + image: ollama/ollama + container_name: kowalski-ollama + restart: unless-stopped + volumes: + - ./ollama:/root/.ollama \ No newline at end of file diff --git a/docker-compose.yml.example b/docker-compose.yml.example new file mode 100644 index 0000000..f3bb819 --- /dev/null +++ b/docker-compose.yml.example @@ -0,0 +1,9 @@ +services: + kowalski: + build: . + container_name: kowalski + restart: unless-stopped + volumes: + - ./.env:/usr/src/app/.env:ro + environment: + - NODE_ENV=production \ No newline at end of file -- 2.47.2 From 06e7d026a489816a943e934bbfa246ff7514a6a7 Mon Sep 17 00:00:00 2001 From: Aidan Date: Sun, 4 May 2025 00:42:54 -0400 Subject: [PATCH 03/12] feat: add initial /ask command --- .env.example | 3 + .gitignore | 8 +- src/commands/ai.ts | 248 ++++++++++++++++++++++++++++++++++++ src/locales/english.json | 4 +- src/locales/portuguese.json | 5 +- src/utils/log.ts | 84 ++++++++++++ src/utils/rate-limiter.ts | 235 ++++++++++++++++++++++++++++++++++ 7 files changed, 584 insertions(+), 3 deletions(-) create mode 100644 src/commands/ai.ts create mode 100644 src/utils/log.ts create mode 100644 src/utils/rate-limiter.ts diff --git a/.env.example b/.env.example index 452211b..997fbc5 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ botSource = "https://github.com/ABOCN/TelegramBot" # insert token here botToken = "" +# ai features +# ollamaApi = "http://ollama:11434" + # misc (botAdmins isnt a array here!) maxRetries = 9999 botAdmins = 00000000, 00000000, 00000000 diff --git a/.gitignore b/.gitignore index 6b42f1f..278fef8 100644 --- a/.gitignore +++ b/.gitignore @@ -144,4 +144,10 @@ yt-dlp ffmpeg # Bun -bun.lock* \ No newline at end of file +bun.lock* + +# Ollama +ollama/ + +# Docker +docker-compose.yml \ No newline at end of file diff --git a/src/commands/ai.ts b/src/commands/ai.ts new file mode 100644 index 0000000..0ac6e53 --- /dev/null +++ b/src/commands/ai.ts @@ -0,0 +1,248 @@ +// AI.TS +// by ihatenodejs/Aidan +// +// ----------------------------------------------------------------------- +// +// This is free and unencumbered software released into the public domain. +// +// Anyone is free to copy, modify, publish, use, compile, sell, or +// distribute this software, either in source code form or as a compiled +// binary, for any purpose, commercial or non-commercial, and by any +// means. +// +// In jurisdictions that recognize copyright laws, the author or authors +// of this software dedicate any and all copyright interest in the +// software to the public domain. We make this dedication for the benefit +// of the public at large and to the detriment of our heirs and +// successors. We intend this dedication to be an overt act of +// relinquishment in perpetuity of all present and future rights to this +// software under copyright law. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// For more information, please refer to + +import { isOnSpamWatch } from "../spamwatch/spamwatch" +import spamwatchMiddlewareModule from "../spamwatch/Middleware" +import { Telegraf, Context } from "telegraf" +import type { Message } from "telegraf/types" +import { replyToMessageId } from "../utils/reply-to-message-id" +import { getStrings } from "../plugins/checklang" +import { languageCode } from "../utils/language-code" +import axios from "axios" +import { rateLimiter } from "../utils/rate-limiter" + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch) +//const model = "qwen3:0.6b" +const model = "deepseek-r1:1.5b" + +type TextContext = Context & { message: Message.TextMessage } + +export function sanitizeForJson(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') +} + +async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message) { + const Strings = getStrings(languageCode(ctx)) + + if (!ctx.chat) return { + "success": false, + "error": Strings.unexpectedErr.replace("{error}", "No chat found"), + } + + try { + const aiResponse = await axios.post(`${process.env.ollamaApi}/api/generate`, { + model: model, + prompt: prompt, + stream: true, + }, { + responseType: "stream", + }) + + let fullResponse = "" + let thoughts = "" + let thinking = false + let lastUpdate = Date.now() + + for await (const chunk of aiResponse.data) { + const lines = chunk.toString().split('\n') + for (const line of lines) { + if (!line.trim()) continue + if (line.includes("\u003cthink\u003e")) { + // intercept thoughts + console.log("thinking started") + thinking = true + thoughts += line + continue + } else if (line.includes("\u003c/think\u003e")) { + // thinking finished + thinking = false + console.log("thinking finished") + continue + } + + try { + const now = Date.now() + let data = JSON.parse(line) + + if (data.response && !thinking) { + fullResponse += data.response + if (now - lastUpdate >= 1000) { + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + fullResponse, + { parse_mode: 'Markdown' } + ) + lastUpdate = now + } + } else if (data.response && thinking) { + if (now - lastUpdate >= 1000) { + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + thoughts, + { parse_mode: 'Markdown' } + ) + lastUpdate = now + } + } + } catch (e) { + console.error("Error parsing chunk:", e) + } + } + } + + return { + "success": true, + "response": fullResponse, + } + } catch (error: any) { + let shouldPullModel = false + + if (error.response?.data?.error) { + if (error.response.data.error.includes(`model '${model}' not found`) || error.status === 404) { + shouldPullModel = true + } else { + console.error("[!] 1", error.response.data.error) + return { + "success": false, + "error": error.response.data.error, + } + } + } else if (error.status === 404) { + shouldPullModel = true + } + + if (shouldPullModel) { + ctx.telegram.editMessageText(ctx.chat.id, replyGenerating.message_id, undefined, `🔄 Pulling ${model} from ollama...`) + console.log(`[i] Pulling ${model} from ollama...`) + + const pullModelStream = await axios.post(`${process.env.ollamaApi}/api/pull`, { + model: model, + stream: false, + }) + + if (pullModelStream.data.status !== ("success")) { + console.error("[!] Something went wrong:", pullModelStream.data) + return { + "success": false, + "error": `❌ Something went wrong while pulling ${model}, please try your command again!`, + } + } + + console.log("[i] Model pulled successfully") + return { + "success": true, + "response": `✅ Pulled ${model} successfully, please retry the command.`, + } + } + + if (error.response) { + console.error("[!] 2", error.response) + return { + "success": false, + "error": error.response, + } + } + + if (error.statusText) { + console.error("[!] 3", error.statusText) + return { + "success": false, + "error": error.statusText, + } + } + + return { + "success": false, + "error": "An unexpected error occurred", + } + } +} + +export default (bot: Telegraf) => { + bot.command("ask", spamwatchMiddleware, async (ctx) => { + if (!ctx.message || !('text' in ctx.message)) return; + const textCtx = ctx as TextContext; + const reply_to_message_id = replyToMessageId(textCtx) + const Strings = getStrings(languageCode(textCtx)) + const message = textCtx.message.text + + if (!process.env.ollamaApi) { + await ctx.reply(Strings.aiDisabled, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + return + } + + const replyGenerating = await ctx.reply(Strings.askGenerating.replace("{model}", model), { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + + const prompt = sanitizeForJson( +`You are a helpful assistant named Kowalski, who has been given a message from a user. + +The message is: + +${message}`) + const aiResponse = await getResponse(prompt, textCtx, replyGenerating) + if (!aiResponse) return + + if (aiResponse.success && aiResponse.response) { + if (!ctx.chat) return + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + aiResponse.response, + { parse_mode: 'Markdown' } + ) + } else { + if (!ctx.chat) return + const error = Strings.unexpectedErr.replace("{error}", aiResponse.error) + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + error, + { parse_mode: 'Markdown' } + ) + } + }) +} \ No newline at end of file diff --git a/src/locales/english.json b/src/locales/english.json index f8bb0ec..53c33fb 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -114,5 +114,7 @@ "apiErr": "An error occurred while fetching data from the API.\n\n`{err}`" }, "chatNotFound": "Chat not found.", - "noFileProvided": "Please provide a file to send." + "noFileProvided": "Please provide a file to send.", + "askGenerating": "✨ _{model} is working..._", + "aiDisabled": "AI features are currently disabled" } \ No newline at end of file diff --git a/src/locales/portuguese.json b/src/locales/portuguese.json index 2e485f7..9c437fb 100644 --- a/src/locales/portuguese.json +++ b/src/locales/portuguese.json @@ -112,5 +112,8 @@ "notFound": "Celular não encontrado.", "resultMsg": "*Nome:* `{name}`\n*Marca:* `{brand}`\n*Modelo:* `{model}`\n*Codinome:* `{codename}`", "apiErr": "Ocorreu um erro ao buscar os dados da API.\n\n`{err}`" - } + }, + "noFileProvided": "Por favor, forneça um arquivo para envio.", + "askGenerating": "✨ _{modelo} está funcionando..._", + "aiDisabled": "Os recursos de IA estão desativados no momento" } diff --git a/src/utils/log.ts b/src/utils/log.ts new file mode 100644 index 0000000..ceae23f --- /dev/null +++ b/src/utils/log.ts @@ -0,0 +1,84 @@ +// LOG.TS +// by ihatenodejs/Aidan +// +// ----------------------------------------------------------------------- +// +// This is free and unencumbered software released into the public domain. +// +// Anyone is free to copy, modify, publish, use, compile, sell, or +// distribute this software, either in source code form or as a compiled +// binary, for any purpose, commercial or non-commercial, and by any +// means. +// +// In jurisdictions that recognize copyright laws, the author or authors +// of this software dedicate any and all copyright interest in the +// software to the public domain. We make this dedication for the benefit +// of the public at large and to the detriment of our heirs and +// successors. We intend this dedication to be an overt act of +// relinquishment in perpetuity of all present and future rights to this +// software under copyright law. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// For more information, please refer to + +class Logger { + private static instance: Logger + private thinking: boolean = false + + private constructor() {} + + static getInstance(): Logger { + if (!Logger.instance) { + Logger.instance = new Logger() + } + return Logger.instance + } + + logChunk(chatId: number, messageId: number, text: string, isOverflow: boolean = false): void { + const prefix = isOverflow ? '[OVERFLOW]' : '[CHUNK]' + console.log(`${prefix} [${chatId}:${messageId}] ${text.length} chars: ${text.substring(0, 50)}${text.length > 50 ? '...' : ''}`) + } + + logPrompt(prompt: string): void { + console.log(`[PROMPT] ${prompt.length} chars: ${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}`) + } + + logThinkingStart(): void { + if (!this.thinking) { + console.log('[THINKING] started') + this.thinking = true + } + } + + logThinkingEnd(): void { + if (this.thinking) { + console.log('[THINKING] ended') + this.thinking = false + } + } + + logError(error: any): void { + if (error.response?.error_code === 429) { + const retryAfter = error.response.parameters?.retry_after || 1 + console.error(`[RATE_LIMIT] Too Many Requests - retry after ${retryAfter}s`) + } else if (error.response?.error_code === 400 && error.response?.description?.includes("can't parse entities")) { + console.error('[PARSE_ERROR] Markdown parsing failed, retrying with plain text') + } else { + const errorDetails = { + code: error.response?.error_code, + description: error.response?.description, + method: error.on?.method + } + console.error('[ERROR]', JSON.stringify(errorDetails, null, 2)) + } + } +} + +export const logger = Logger.getInstance() \ No newline at end of file diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts new file mode 100644 index 0000000..3c1d537 --- /dev/null +++ b/src/utils/rate-limiter.ts @@ -0,0 +1,235 @@ +// RATE-LIMITER.TS +// by ihatenodejs/Aidan +// +// ----------------------------------------------------------------------- +// +// This is free and unencumbered software released into the public domain. +// +// Anyone is free to copy, modify, publish, use, compile, sell, or +// distribute this software, either in source code form or as a compiled +// binary, for any purpose, commercial or non-commercial, and by any +// means. +// +// In jurisdictions that recognize copyright laws, the author or authors +// of this software dedicate any and all copyright interest in the +// software to the public domain. We make this dedication for the benefit +// of the public at large and to the detriment of our heirs and +// successors. We intend this dedication to be an overt act of +// relinquishment in perpetuity of all present and future rights to this +// software under copyright law. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// For more information, please refer to + +import { Context } from 'telegraf' +import { logger } from './log' + +class RateLimiter { + private lastEditTime: number = 0 + private readonly minInterval: number = 5000 + private pendingUpdates: Map = new Map() + private updateQueue: Map = new Map() + private readonly max_msg_length: number = 3500 + private overflowMessages: Map = new Map() + private isRateLimited: boolean = false + private rateLimitEndTime: number = 0 + + private getMessageKey(chatId: number, messageId: number): string { + return `${chatId}:${messageId}` + } + + private async waitForRateLimit(): Promise { + if (this.isRateLimited) { + const now = Date.now() + if (now < this.rateLimitEndTime) { + const waitTime = this.rateLimitEndTime - now + await new Promise(resolve => setTimeout(resolve, waitTime)) + } + this.isRateLimited = false + } + } + + private async processUpdate( + ctx: Context, + chatId: number, + messageId: number, + options: any + ): Promise { + const messageKey = this.getMessageKey(chatId, messageId) + const latestText = this.pendingUpdates.get(messageKey) + if (!latestText) return + + const now = Date.now() + const timeSinceLastEdit = now - this.lastEditTime + + await this.waitForRateLimit() + + if (timeSinceLastEdit < this.minInterval) { + const existingTimeout = this.updateQueue.get(messageKey) + if (existingTimeout) { + clearTimeout(existingTimeout) + } + + const timeout = setTimeout(() => { + this.processUpdate(ctx, chatId, messageId, options) + }, this.minInterval - timeSinceLastEdit) + + this.updateQueue.set(messageKey, timeout) + return + } + + try { + if (latestText.length > this.max_msg_length) { + const chunks: string[] = [] + let currentChunk = '' + let currentLength = 0 + + // Split text into chunks while preserving markdown formatting + const lines = latestText.split('\n') + for (const line of lines) { + if (currentLength + line.length + 1 > this.max_msg_length) { + if (currentChunk) { + chunks.push(currentChunk) + currentChunk = '' + currentLength = 0 + } + // if a single line is too long, split + if (line.length > this.max_msg_length) { + for (let i = 0; i < line.length; i += this.max_msg_length) { + chunks.push(line.substring(i, i + this.max_msg_length)) + } + } else { + currentChunk = line + currentLength = line.length + } + } else { + if (currentChunk) { + currentChunk += '\n' + currentLength++ + } + currentChunk += line + currentLength += line.length + } + } + if (currentChunk) { + chunks.push(currentChunk) + } + + const firstChunk = chunks[0] + logger.logChunk(chatId, messageId, firstChunk) + + try { + await ctx.telegram.editMessageText(chatId, messageId, undefined, firstChunk, options) + } catch (error: any) { + if (!error.response?.description?.includes("message is not modified")) { + throw error + } + } + + for (let i = 1; i < chunks.length; i++) { + const chunk = chunks[i] + const overflowMessageId = this.overflowMessages.get(messageKey) + + if (overflowMessageId) { + logger.logChunk(chatId, overflowMessageId, chunk, true) + try { + await ctx.telegram.editMessageText(chatId, overflowMessageId, undefined, chunk, options) + } catch (error: any) { + if (!error.response?.description?.includes("message is not modified")) { + throw error + } + } + } else { + const newMessage = await ctx.telegram.sendMessage(chatId, chunk, { + ...options, + reply_to_message_id: messageId + }) + logger.logChunk(chatId, newMessage.message_id, chunk, true) + this.overflowMessages.set(messageKey, newMessage.message_id) + } + } + + this.pendingUpdates.set(messageKey, firstChunk) + if (chunks.length > 1) { + this.pendingUpdates.set( + this.getMessageKey(chatId, this.overflowMessages.get(messageKey)!), + chunks[chunks.length - 1] + ) + } + } else { + const sanitizedText = latestText + logger.logChunk(chatId, messageId, sanitizedText) + + try { + await ctx.telegram.editMessageText(chatId, messageId, undefined, sanitizedText, options) + } catch (error: any) { + if (!error.response?.description?.includes("message is not modified")) { + throw error + } + } + this.pendingUpdates.delete(messageKey) + } + + this.lastEditTime = Date.now() + this.updateQueue.delete(messageKey) + } catch (error: any) { + if (error.response?.error_code === 429) { + const retryAfter = error.response.parameters?.retry_after || 1 + this.isRateLimited = true + this.rateLimitEndTime = Date.now() + (retryAfter * 1000) + + const existingTimeout = this.updateQueue.get(messageKey) + if (existingTimeout) { + clearTimeout(existingTimeout) + } + + const timeout = setTimeout(() => { + this.processUpdate(ctx, chatId, messageId, options) + }, retryAfter * 1000) + + this.updateQueue.set(messageKey, timeout) + } else if (error.response?.error_code === 400) { + if (error.response?.description?.includes("can't parse entities")) { + // try again with plain text + const plainOptions = { ...options, parse_mode: undefined } + await this.processUpdate(ctx, chatId, messageId, plainOptions) + } else if (error.response?.description?.includes("MESSAGE_TOO_LONG")) { + const plainOptions = { ...options, parse_mode: undefined } + await this.processUpdate(ctx, chatId, messageId, plainOptions) + } else if (error.response?.description?.includes("message is not modified")) { + this.pendingUpdates.delete(messageKey) + this.updateQueue.delete(messageKey) + } else { + logger.logError(error) + this.pendingUpdates.delete(messageKey) + this.updateQueue.delete(messageKey) + } + } else { + logger.logError(error) + this.pendingUpdates.delete(messageKey) + this.updateQueue.delete(messageKey) + } + } + } + + async editMessageWithRetry( + ctx: Context, + chatId: number, + messageId: number, + text: string, + options: any + ): Promise { + const messageKey = this.getMessageKey(chatId, messageId) + this.pendingUpdates.set(messageKey, text) + await this.processUpdate(ctx, chatId, messageId, options) + } +} + +export const rateLimiter = new RateLimiter() \ No newline at end of file -- 2.47.2 From f987bfec21bb3f0218de3704d77e03e4cb7f290f Mon Sep 17 00:00:00 2001 From: Aidan Date: Sun, 4 May 2025 12:43:10 -0400 Subject: [PATCH 04/12] Delete docker-compose.yml --- docker-compose.yml | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 0aab44a..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,9 +0,0 @@ -services: - kowalski: - build: . - container_name: kowalski - restart: unless-stopped - volumes: - - ./.env:/usr/src/app/.env:ro - environment: - - NODE_ENV=production \ No newline at end of file -- 2.47.2 From f6863c448b76408e635096541225ea5fe5310135 Mon Sep 17 00:00:00 2001 From: Aidan Date: Sun, 4 May 2025 13:01:46 -0400 Subject: [PATCH 05/12] docker: ignore ollama folder in builds --- .dockerignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index a082d1a..9fe19f3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,5 @@ npm-debug.log .gitignore .env *.md -!README.md \ No newline at end of file +!README.md +ollama/ \ No newline at end of file -- 2.47.2 From e77c145effcbb657edb28c80a8e6cd6b6a01c647 Mon Sep 17 00:00:00 2001 From: Aidan Date: Sun, 4 May 2025 13:02:09 -0400 Subject: [PATCH 06/12] fix: add emojis to help commands, capitalize, add ai commands to help menu --- src/commands/help.ts | 7 ++++++- src/locales/english.json | 40 +++++++++++++++++++------------------ src/locales/portuguese.json | 2 ++ 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/commands/help.ts b/src/commands/help.ts index 39191c1..3a6d3a0 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -32,7 +32,8 @@ async function sendHelpMessage(ctx, isEditing) { [{ text: Strings.mainCommands, callback_data: 'helpMain' }, { text: Strings.usefulCommands, callback_data: 'helpUseful' }], [{ text: Strings.interactiveEmojis, callback_data: 'helpInteractive' }, { text: Strings.funnyCommands, callback_data: 'helpFunny' }], [{ text: Strings.lastFm.helpEntry, callback_data: 'helpLast' }, { text: Strings.animalCommands, callback_data: 'helpAnimals' }], - [{ text: Strings.ytDownload.helpEntry, callback_data: 'helpYouTube' }, { text: Strings.ponyApi.helpEntry, callback_data: 'helpMLP' }] + [{ text: Strings.ytDownload.helpEntry, callback_data: 'helpYouTube' }, { text: Strings.ponyApi.helpEntry, callback_data: 'helpMLP' }], + [{ text: Strings.aiCmds, callback_data: 'helpAi' }] ] } }; @@ -112,6 +113,10 @@ export default (bot) => { await ctx.answerCbQuery(); await ctx.editMessageText(Strings.ponyApi.helpDesc, options); break; + case 'helpAi': + await ctx.answerCbQuery(); + await ctx.editMessageText(Strings.aiCmdsDesc, options); + break; case 'helpBack': await ctx.answerCbQuery(); await sendHelpMessage(ctx, true); diff --git a/src/locales/english.json b/src/locales/english.json index 53c33fb..76cef32 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -33,8 +33,8 @@ "funEmojiResult": "*You rolled {emoji} and got* `{value}`*!*\nYou don't know what that means? Me neither!", "gifErr": "*Something went wrong while sending the GIF. Please try again later.*\n\n{err}", "lastFm": { - "helpEntry": "Last.fm", - "helpDesc": "*Last.fm*\n\n- /lt | /lmu | /last | /lfm: Shows the last song from your Last.fm profile + the number of plays.\n- /setuser ``: Sets the user for the command above.", + "helpEntry": "🎵 Last.fm", + "helpDesc": "🎵 *Last.fm*\n\n- /lt | /lmu | /last | /lfm: Shows the last song from your Last.fm profile + the number of plays.\n- /setuser ``: Sets the user for the command above.", "noUser": "*Please provide a Last.fm username.*\nExample: `/setuser `", "noUserSet": "*You haven't set your Last.fm username yet.*\nUse the command /setuser to set.\n\nExample: `/setuser `", "noRecentTracks": "*No recent tracks found for Last.fm user* `{lastfmUser}`*.*", @@ -52,25 +52,27 @@ "apiErr": "*An error occurred while retrieving the weather. Please try again later.*\n\n`{error}`", "apiKeyErr": "*An API key was not set by the bot owner. Please try again later.*" }, - "mainCommands": "Main commands", - "mainCommandsDesc": "*Main commands*\n\n- /help: Show bot's help\n- /start: Start the bot\n- /privacy: Read the bot's Privacy Policy", - "usefulCommands": "Useful commands", - "usefulCommandsDesc": "*Useful commands*\n\n- /chatinfo: Send information about the group\n- /userinfo: Send information about yourself\n- /d | /device ``: Search for a device on GSMArena and show its specs.\n/codename | /whatis ``: Shows what device is based on the codename. Example: `/codename begonia`\n- /weather | /clima ``: See weather status for a specific location.\n- /modarchive | /tma ``: Download a module from The Mod Archive.\n- /http ``: Send details about a specific HTTP code. Example: `/http 404`", - "funnyCommands": "Funny commands", - "funnyCommandsDesc": "*Funny commands*\n\n- /gay: Check if you are gay\n- /furry: Check if you are a furry\n- /random: Pick a random number between 0-10", - "interactiveEmojis": "Interactive emojis", - "interactiveEmojisDesc": "*Interactive emojis*\n\n- /dice: Roll a dice\n- /idice: Infinitely roll a colored dice\n- /slot: Try to combine the figures!\n- /ball: Try to kick the ball into the goal!\n- /bowling: Try to hit the pins!\n- /dart: Try to hit the target!", - "animalCommands": "Animals", - "animalCommandsDesc": "*Animals*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Sends the [Soggy cat meme](https://knowyourmeme.com/memes/soggy-cat)\n- /cat: Sends a random picture of a cat.\n- /fox: Sends a random picture of a fox.\n- /duck: Sends a random picture of a duck.\n- /dog: Sends a random picture of a dog.\n- /httpcat ``: Send cat memes from http.cat with your specified HTTP code. Example: `/httpcat 404`", + "mainCommands": "ℹ️ Main Commands", + "mainCommandsDesc": "ℹ️ *Main Commands*\n\n- /help: Show bot's help\n- /start: Start the bot\n- /privacy: Read the bot's Privacy Policy", + "usefulCommands": "🛠️ Useful Commands", + "usefulCommandsDesc": "🛠️ *Useful commands*\n\n- /chatinfo: Send information about the group\n- /userinfo: Send information about yourself\n- /d | /device ``: Search for a device on GSMArena and show its specs.\n/codename | /whatis ``: Shows what device is based on the codename. Example: `/codename begonia`\n- /weather | /clima ``: See weather status for a specific location.\n- /modarchive | /tma ``: Download a module from The Mod Archive.\n- /http ``: Send details about a specific HTTP code. Example: `/http 404`", + "funnyCommands": "😂 Funny Commands", + "funnyCommandsDesc": "😂 *Funny Commands*\n\n- /gay: Check if you are gay\n- /furry: Check if you are a furry\n- /random: Pick a random number between 0-10", + "interactiveEmojis": "🎲 Interactive Emojis", + "interactiveEmojisDesc": "🎲 *Interactive emojis*\n\n- /dice: Roll a dice\n- /idice: Infinitely roll a colored dice\n- /slot: Try to combine the figures!\n- /ball: Try to kick the ball into the goal!\n- /bowling: Try to hit the pins!\n- /dart: Try to hit the target!", + "animalCommands": "🐱 Animals", + "animalCommandsDesc": "🐱 *Animals*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Sends the [Soggy cat meme](https://knowyourmeme.com/memes/soggy-cat)\n- /cat: Sends a random picture of a cat.\n- /fox: Sends a random picture of a fox.\n- /duck: Sends a random picture of a duck.\n- /dog: Sends a random picture of a dog.\n- /httpcat ``: Send cat memes from http.cat with your specified HTTP code. Example: `/httpcat 404`", + "aiCmds": "✨ AI Commands", + "aiCmdsDesc": "✨ *AI Commands*\n\n- /ask ``: Ask a question to an AI", "maInvalidModule": "Please provide a valid module ID from The Mod Archive.\nExample: `/modarchive 81574`", "maDownloadError": "Error downloading the file. Check the module ID and try again.", "ytDownload": { - "helpEntry": "Video download", - "helpDesc": "*Video download*\n\n- /yt | /ytdl | /sdl | /dl | /video ` -Translation status - - ## Self-host requirements > [!IMPORTANT] @@ -26,7 +20,10 @@ Kowalski is a a simple Telegram bot made in Node.js. - FFmpeg (only for the `/yt` command) - Docker and Docker Compose (only required for Docker setup) -_AI features require a higher-end system with a CPU/GPU_ +### AI Requirements + +- High-end CPU *or* GPU (~ 6GB vRAM) +- If using CPU, enough RAM to load the models (~6GB w/ defaults) ## Running locally (non-Docker setup) @@ -60,7 +57,7 @@ You can also run Kowalski using Docker, which simplifies the setup process. Make 1. **Copy compose file** _Without AI (Ollama)_ - + ```bash mv docker-compose.yml.example docker-compose.yml ``` @@ -70,7 +67,7 @@ You can also run Kowalski using Docker, which simplifies the setup process. Make ```bash mv docker-compose.yml.ai.example docker-compose.yml ``` - + 2. **Make sure to setup your `.env` file first!** 3. **Run the container** @@ -109,7 +106,9 @@ If you prefer to use Docker directly, you can use these instructions instead. - **botPrivacy**: Put the link to your bot privacy policy. - **maxRetries**: Maximum number of retries for a failing command on Kowalski. Default is 5. If the limit is hit, the bot will crash past this number. - **botToken**: Put your bot token that you created at [@BotFather](https://t.me/botfather). +- **ollamaEnabled** (optional): Enables/disables AI features - **ollamaApi** (optional): Ollama API endpoint for various AI features, will be disabled if not set +- **handlerTimeout** (default: `600_000`): How long handlers will wait before timing out. Set this high if using large AI models. - **botAdmins**: Put the ID of the people responsible for managing the bot. They can use some administrative + exclusive commands on any group. - **lastKey**: Last.fm API key, for use on `lastfm.js` functions, like see who is listening to what song and etc. - **weatherKey**: Weather.com API key, used for the `/weather` command. diff --git a/src/bot.ts b/src/bot.ts index 3422e56..04d2c97 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import { isOnSpamWatch } from './spamwatch/spamwatch'; import '@dotenvx/dotenvx'; import './plugins/ytDlpWrapper'; +import { preChecks } from './commands/ai'; // Ensures bot token is set, and not default value if (!process.env.botToken || process.env.botToken === 'InsertYourBotTokenHere') { @@ -11,7 +12,17 @@ if (!process.env.botToken || process.env.botToken === 'InsertYourBotTokenHere') process.exit(1) } -const bot = new Telegraf(process.env.botToken); +// Detect AI and run pre-checks +if (process.env.ollamaEnabled === "true") { + if (!(await preChecks())) { + process.exit(1) + } +} + +const bot = new Telegraf( + process.env.botToken, + { handlerTimeout: Number(process.env.handlerTimeout) || 600_000 } +); const maxRetries = process.env.maxRetries || 5; let restartCount = 0; @@ -21,7 +32,7 @@ const loadCommands = () => { try { const files = fs.readdirSync(commandsPath) .filter(file => file.endsWith('.ts') || file.endsWith('.js')); - + files.forEach((file) => { try { const commandPath = path.join(commandsPath, file); diff --git a/src/commands/ai.ts b/src/commands/ai.ts index e62489c..33330e8 100644 --- a/src/commands/ai.ts +++ b/src/commands/ai.ts @@ -40,8 +40,8 @@ import { rateLimiter } from "../utils/rate-limiter" import { logger } from "../utils/log" const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch) -//const model = "qwen3:0.6b" -const model = "deepseek-r1:1.5b" +export const flash_model = "gemma3:4b" +export const thinking_model = "deepseek-r1:1.5b" type TextContext = Context & { message: Message.TextMessage } @@ -54,7 +54,22 @@ export function sanitizeForJson(text: string): string { .replace(/\t/g, '\\t') } -async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message) { +export async function preChecks() { + const envs = [ + "ollamaApi", + ] + + for (const env of envs) { + if (!process.env[env]) { + console.error(`[✨ AI | !] ❌ ${env} not set!`) + return false + } + } + console.log("[✨ AI] Pre-checks passed\n") + return true +} + +async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message, model: string) { const Strings = getStrings(languageCode(ctx)) if (!ctx.chat) return { @@ -74,23 +89,50 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me let fullResponse = "" let thoughts = "" let lastUpdate = Date.now() - + for await (const chunk of aiResponse.data) { const lines = chunk.toString().split('\n') for (const line of lines) { if (!line.trim()) continue let ln = JSON.parse(line) - - if (ln.response.includes("")) { logger.logThinking(true) } else if (ln.response.includes("")) { logger.logThinking(false) } + + if (model === thinking_model && ln.response.includes('')) { + const thinkMatch = ln.response.match(/([\s\S]*?)<\/think>/) + if (thinkMatch) { + const innerContent = thinkMatch[1] + if (innerContent.trim().length > 0) { + logger.logThinking(ctx.chat.id, replyGenerating.message_id, true) + } + } else { + logger.logThinking(ctx.chat.id, replyGenerating.message_id, true) + } + } else if (model === thinking_model && ln.response.includes('')) { + logger.logThinking(ctx.chat.id, replyGenerating.message_id, false) + } try { const now = Date.now() - - if (ln.response) { - const patchedThoughts = ln.response.replace("", "`Thinking...`").replace("", "`Finished thinking`") - thoughts += patchedThoughts - fullResponse += patchedThoughts + if (ln.response) { + if (model === thinking_model) { + let patchedThoughts = ln.response + // TODO: hide blank thinking chunks + const thinkTagRx = /([\s\S]*?)<\/think>/g + patchedThoughts = patchedThoughts.replace(thinkTagRx, (match, p1) => { + if (p1.trim().length > 0) { + console.log(p1) + return '`Thinking...`' + p1 + '`Finished thinking`' + } else { + return '' + } + }) + patchedThoughts = patchedThoughts.replace(//g, '`Thinking...`') + patchedThoughts = patchedThoughts.replace(/<\/think>/g, '`Finished thinking`') + thoughts += patchedThoughts + fullResponse += patchedThoughts + } else { + fullResponse += ln.response + } if (now - lastUpdate >= 1000) { await rateLimiter.editMessageWithRetry( ctx, @@ -103,7 +145,7 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me } } } catch (e) { - console.error("Error parsing chunk:", e) + console.error("[✨ AI | !] Error parsing chunk:", e) } } } @@ -119,7 +161,7 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me if (error.response.data.error.includes(`model '${model}' not found`) || error.status === 404) { shouldPullModel = true } else { - console.error("[!] 1", error.response.data.error) + console.error("[✨ AI | !] Error zone 1:", error.response.data.error) return { "success": false, "error": error.response.data.error, @@ -130,23 +172,25 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me } if (shouldPullModel) { - ctx.telegram.editMessageText(ctx.chat.id, replyGenerating.message_id, undefined, `🔄 Pulling ${model} from ollama...`) - console.log(`[i] Pulling ${model} from ollama...`) + ctx.telegram.editMessageText(ctx.chat.id, replyGenerating.message_id, undefined, `🔄 Pulling ${model} from ollama...\n\nThis may take a few minutes...`) + console.log(`[✨ AI | i] Pulling ${model} from ollama...`) + let pullModelStream: any - const pullModelStream = await axios.post(`${process.env.ollamaApi}/api/pull`, { - model: model, - stream: false, - }) - - if (pullModelStream.data.status !== ("success")) { - console.error("[!] Something went wrong:", pullModelStream.data) + try { + pullModelStream = await axios.post(`${process.env.ollamaApi}/api/pull`, { + model: model, + stream: false, + timeout: process.env.ollamaApiTimeout || 10000, + }) + } catch (e: any) { + console.error("[✨ AI | !] Something went wrong:", e.response.data.error) return { "success": false, "error": `❌ Something went wrong while pulling ${model}, please try your command again!`, } } - console.log("[i] Model pulled successfully") + console.log(`[✨ AI | i] ${model} pulled successfully`) return { "success": true, "response": `✅ Pulled ${model} successfully, please retry the command.`, @@ -154,7 +198,7 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me } if (error.response) { - console.error("[!] 2", error.response) + console.error("[✨ AI | !] Error zone 2:", error.response) return { "success": false, "error": error.response, @@ -162,7 +206,7 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me } if (error.statusText) { - console.error("[!] 3", error.statusText) + console.error("[✨ AI | !] Error zone 3:", error.statusText) return { "success": false, "error": error.statusText, @@ -177,15 +221,24 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me } export default (bot: Telegraf) => { - bot.command("ask", spamwatchMiddleware, async (ctx) => { + const botName = bot.botInfo?.first_name && bot.botInfo?.last_name ? `${bot.botInfo.first_name} ${bot.botInfo.last_name}` : "Kowalski" + + bot.command(["ask", "think"], spamwatchMiddleware, async (ctx) => { if (!ctx.message || !('text' in ctx.message)) return; + const isAsk = ctx.message.text.startsWith("/ask") + const model = isAsk ? flash_model : thinking_model + console.log(model) + console.log(ctx.message.text) const textCtx = ctx as TextContext; const reply_to_message_id = replyToMessageId(textCtx) const Strings = getStrings(languageCode(textCtx)) const message = textCtx.message.text const author = ("@" + ctx.from?.username) || ctx.from?.first_name - logger.logCmdStart(author) + logger.logCmdStart( + author, + model === flash_model ? "ask" : "think" + ) if (!process.env.ollamaApi) { await ctx.reply(Strings.aiDisabled, { @@ -212,12 +265,14 @@ export default (bot: Telegraf) => { logger.logPrompt(fixedMsg) const prompt = sanitizeForJson( -`You are a helpful assistant named Kowalski, who has been given a message from a user. +`You are a helpful assistant called ${botName}. +Current Date/Time (UTC): ${new Date().toLocaleString()} -The message is: +--- +Respond to the user's message: ${fixedMsg}`) - const aiResponse = await getResponse(prompt, textCtx, replyGenerating) + const aiResponse = await getResponse(prompt, textCtx, replyGenerating, model) if (!aiResponse) return if (aiResponse.success && aiResponse.response) { @@ -239,7 +294,6 @@ ${fixedMsg}`) error, { parse_mode: 'Markdown' } ) - console.error("[!] Error sending response:", aiResponse.error) } }) } \ No newline at end of file diff --git a/src/utils/log.ts b/src/utils/log.ts index b70e5f3..63c046a 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -28,6 +28,8 @@ // // For more information, please refer to +import { flash_model, thinking_model } from "../commands/ai" + class Logger { private static instance: Logger private thinking: boolean = false @@ -41,42 +43,42 @@ class Logger { return Logger.instance } - logCmdStart(user: string): void { - console.log(`[START] Received /ask from ${user}`) + logCmdStart(user: string, type: "ask" | "think"): void { + console.log(`\n[✨ AI | START] Received /${type} for model ${type === "ask" ? flash_model : thinking_model} from ${user}`) } - logThinking(thinking: boolean): void { + logThinking(chatId: number, messageId: number, thinking: boolean): void { if (thinking) { - console.log("[THINKING] Started") + console.log(`[✨ AI | THINKING | ${chatId}:${messageId}] Model started thinking`) } else { - console.log("[THINKING] Ended") + console.log(`[✨ AI | THINKING | ${chatId}:${messageId}] Model stopped thinking`) } } logChunk(chatId: number, messageId: number, text: string, isOverflow: boolean = false): void { - const prefix = isOverflow ? "[OVERFLOW]" : "[CHUNK]" - console.log(`${prefix} [${chatId}:${messageId}] ${text.length} chars`) + const prefix = isOverflow ? "[✨ AI | OVERFLOW]" : "[✨ AI | CHUNK]" + console.log(`${prefix} [${chatId}:${messageId}] ${text.length} chars pushed to Telegram`) } logPrompt(prompt: string): void { - console.log(`[PROMPT] ${prompt.length} chars: ${prompt.substring(0, 50)}${prompt.length > 50 ? "..." : ""}`) + console.log(`[✨ AI | PROMPT] ${prompt.length} chars: ${prompt.substring(0, 50)}${prompt.length > 50 ? "..." : ""}`) } logError(error: any): void { if (error.response?.error_code === 429) { const retryAfter = error.response.parameters?.retry_after || 1 - console.error(`[RATE_LIMIT] Too Many Requests - retry after ${retryAfter}s`) + console.error(`[✨ AI | RATE_LIMIT] Too Many Requests - retry after ${retryAfter}s`) } else if (error.response?.error_code === 400 && error.response?.description?.includes("can't parse entities")) { - console.error("[PARSE_ERROR] Markdown parsing failed, retrying with plain text") + console.error("[✨ AI | PARSE_ERROR] Markdown parsing failed, retrying with plain text") } else { const errorDetails = { code: error.response?.error_code, description: error.response?.description, method: error.on?.method } - console.error("[ERROR]", JSON.stringify(errorDetails, null, 2)) + console.error("[✨ AI | ERROR]", JSON.stringify(errorDetails, null, 2)) } } } -export const logger = Logger.getInstance() \ No newline at end of file +export const logger = Logger.getInstance() \ No newline at end of file diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts index 3c1d537..4b4a324 100644 --- a/src/utils/rate-limiter.ts +++ b/src/utils/rate-limiter.ts @@ -80,7 +80,7 @@ class RateLimiter { const timeout = setTimeout(() => { this.processUpdate(ctx, chatId, messageId, options) }, this.minInterval - timeSinceLastEdit) - + this.updateQueue.set(messageKey, timeout) return } @@ -124,7 +124,7 @@ class RateLimiter { const firstChunk = chunks[0] logger.logChunk(chatId, messageId, firstChunk) - + try { await ctx.telegram.editMessageText(chatId, messageId, undefined, firstChunk, options) } catch (error: any) { @@ -136,11 +136,11 @@ class RateLimiter { for (let i = 1; i < chunks.length; i++) { const chunk = chunks[i] const overflowMessageId = this.overflowMessages.get(messageKey) - + if (overflowMessageId) { - logger.logChunk(chatId, overflowMessageId, chunk, true) try { await ctx.telegram.editMessageText(chatId, overflowMessageId, undefined, chunk, options) + logger.logChunk(chatId, overflowMessageId, chunk, true) } catch (error: any) { if (!error.response?.description?.includes("message is not modified")) { throw error @@ -166,7 +166,7 @@ class RateLimiter { } else { const sanitizedText = latestText logger.logChunk(chatId, messageId, sanitizedText) - + try { await ctx.telegram.editMessageText(chatId, messageId, undefined, sanitizedText, options) } catch (error: any) { @@ -184,7 +184,7 @@ class RateLimiter { const retryAfter = error.response.parameters?.retry_after || 1 this.isRateLimited = true this.rateLimitEndTime = Date.now() + (retryAfter * 1000) - + const existingTimeout = this.updateQueue.get(messageKey) if (existingTimeout) { clearTimeout(existingTimeout) @@ -193,7 +193,7 @@ class RateLimiter { const timeout = setTimeout(() => { this.processUpdate(ctx, chatId, messageId, options) }, retryAfter * 1000) - + this.updateQueue.set(messageKey, timeout) } else if (error.response?.error_code === 400) { if (error.response?.description?.includes("can't parse entities")) { -- 2.47.2 From e1e017d32c3b60a85b343a461ec4f3d15376e43d Mon Sep 17 00:00:00 2001 From: Aidan Date: Fri, 27 Jun 2025 03:15:49 -0400 Subject: [PATCH 09/12] clean, remove prompt and user info from logs, more docs edits --- README.md | 7 ++++++- src/commands/ai.ts | 2 -- src/utils/log.ts | 5 ++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f1b8d2b..3cc7c99 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,11 @@ You can also run Kowalski using Docker, which simplifies the setup process. Make 2. **Make sure to setup your `.env` file first!** + > [!TIP] + > If you intend to setup AI, the defaults for Docker are already included (just uncomment) and don't need to be changed. + > + > Further setup may be needed for GPUs. See the Ollama documentation for more. + 3. **Run the container** ```bash @@ -129,7 +134,7 @@ chmod +x src/plugins/yt-dlp/yt-dlp **Q:** How can I disable AI features? -**A:** AI features are disabled by default, unless you have set `ollamaApi` in your `.env` file. Please remove or comment out this line to disable all AI functionality. +**A:** AI features are disabled by default, unless you have set `ollamaEnabled` to `true` in your `.env` file. Set it back to `false` to disable. ## Contributors diff --git a/src/commands/ai.ts b/src/commands/ai.ts index 33330e8..495a36f 100644 --- a/src/commands/ai.ts +++ b/src/commands/ai.ts @@ -227,8 +227,6 @@ export default (bot: Telegraf) => { if (!ctx.message || !('text' in ctx.message)) return; const isAsk = ctx.message.text.startsWith("/ask") const model = isAsk ? flash_model : thinking_model - console.log(model) - console.log(ctx.message.text) const textCtx = ctx as TextContext; const reply_to_message_id = replyToMessageId(textCtx) const Strings = getStrings(languageCode(textCtx)) diff --git a/src/utils/log.ts b/src/utils/log.ts index 63c046a..67019a8 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -32,7 +32,6 @@ import { flash_model, thinking_model } from "../commands/ai" class Logger { private static instance: Logger - private thinking: boolean = false private constructor() {} @@ -44,7 +43,7 @@ class Logger { } logCmdStart(user: string, type: "ask" | "think"): void { - console.log(`\n[✨ AI | START] Received /${type} for model ${type === "ask" ? flash_model : thinking_model} from ${user}`) + console.log(`\n[✨ AI | START] Received /${type} for model ${type === "ask" ? flash_model : thinking_model}`) } logThinking(chatId: number, messageId: number, thinking: boolean): void { @@ -61,7 +60,7 @@ class Logger { } logPrompt(prompt: string): void { - console.log(`[✨ AI | PROMPT] ${prompt.length} chars: ${prompt.substring(0, 50)}${prompt.length > 50 ? "..." : ""}`) + console.log(`[✨ AI | PROMPT] ${prompt.length} chars input`) } logError(error: any): void { -- 2.47.2 From b9173f4bda424e24c5cddbccbe273e4f841b12fd Mon Sep 17 00:00:00 2001 From: Aidan Date: Fri, 27 Jun 2025 03:20:55 -0400 Subject: [PATCH 10/12] system prompt change (plaintext only), parse out /think --- src/commands/ai.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/ai.ts b/src/commands/ai.ts index 495a36f..4badbb5 100644 --- a/src/commands/ai.ts +++ b/src/commands/ai.ts @@ -251,7 +251,7 @@ export default (bot: Telegraf) => { ...({ reply_to_message_id }) }) - const fixedMsg = message.replace(/\/ask /, "") + const fixedMsg = message.replace(/\/ask /, "").replace(/\/think /, "") if (fixedMsg.length < 1) { await ctx.reply(Strings.askNoMessage, { parse_mode: 'Markdown', @@ -263,7 +263,7 @@ export default (bot: Telegraf) => { logger.logPrompt(fixedMsg) const prompt = sanitizeForJson( -`You are a helpful assistant called ${botName}. +`You are a plaintext-only, helpful assistant called ${botName}. Current Date/Time (UTC): ${new Date().toLocaleString()} --- -- 2.47.2 From a4233c5cffe335d9cefa415e4fc1390ee9701f74 Mon Sep 17 00:00:00 2001 From: Aidan Date: Sat, 28 Jun 2025 15:08:48 -0400 Subject: [PATCH 11/12] clean up, axios tweaks --- src/commands/ai.ts | 240 ++++++++++++++++++++++----------------------- 1 file changed, 115 insertions(+), 125 deletions(-) diff --git a/src/commands/ai.ts b/src/commands/ai.ts index 4badbb5..0e27578 100644 --- a/src/commands/ai.ts +++ b/src/commands/ai.ts @@ -72,151 +72,145 @@ export async function preChecks() { async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message, model: string) { const Strings = getStrings(languageCode(ctx)) - if (!ctx.chat) return { - "success": false, - "error": Strings.unexpectedErr.replace("{error}", "No chat found"), + if (!ctx.chat) { + return { + success: false, + error: Strings.unexpectedErr.replace("{error}", "No chat found"), + } } try { - const aiResponse = await axios.post(`${process.env.ollamaApi}/api/generate`, { - model: model, - prompt: prompt, - stream: true, - }, { - responseType: "stream", - }) + const aiResponse = await axios.post( + `${process.env.ollamaApi}/api/generate`, + { + model, + prompt, + stream: true, + }, + { + responseType: "stream", + } + ) let fullResponse = "" let thoughts = "" let lastUpdate = Date.now() - for await (const chunk of aiResponse.data) { + const stream = aiResponse.data + for await (const chunk of stream) { const lines = chunk.toString().split('\n') for (const line of lines) { if (!line.trim()) continue - let ln = JSON.parse(line) - - if (model === thinking_model && ln.response.includes('')) { - const thinkMatch = ln.response.match(/([\s\S]*?)<\/think>/) - if (thinkMatch) { - const innerContent = thinkMatch[1] - if (innerContent.trim().length > 0) { - logger.logThinking(ctx.chat.id, replyGenerating.message_id, true) - } - } else { - logger.logThinking(ctx.chat.id, replyGenerating.message_id, true) - } - } else if (model === thinking_model && ln.response.includes('')) { - logger.logThinking(ctx.chat.id, replyGenerating.message_id, false) - } - + let ln try { - const now = Date.now() - - if (ln.response) { - if (model === thinking_model) { - let patchedThoughts = ln.response - // TODO: hide blank thinking chunks - const thinkTagRx = /([\s\S]*?)<\/think>/g - patchedThoughts = patchedThoughts.replace(thinkTagRx, (match, p1) => { - if (p1.trim().length > 0) { - console.log(p1) - return '`Thinking...`' + p1 + '`Finished thinking`' - } else { - return '' - } - }) - patchedThoughts = patchedThoughts.replace(//g, '`Thinking...`') - patchedThoughts = patchedThoughts.replace(/<\/think>/g, '`Finished thinking`') - thoughts += patchedThoughts - fullResponse += patchedThoughts - } else { - fullResponse += ln.response - } - if (now - lastUpdate >= 1000) { - await rateLimiter.editMessageWithRetry( - ctx, - ctx.chat.id, - replyGenerating.message_id, - thoughts, - { parse_mode: 'Markdown' } - ) - lastUpdate = now - } - } + ln = JSON.parse(line) } catch (e) { console.error("[✨ AI | !] Error parsing chunk:", e) + continue + } + + if (model === thinking_model) { + if (ln.response.includes('')) { + const thinkMatch = ln.response.match(/([\s\S]*?)<\/think>/) + if (thinkMatch && thinkMatch[1].trim().length > 0) { + logger.logThinking(ctx.chat.id, replyGenerating.message_id, true) + } else if (!thinkMatch) { + logger.logThinking(ctx.chat.id, replyGenerating.message_id, true) + } + } else if (ln.response.includes('')) { + logger.logThinking(ctx.chat.id, replyGenerating.message_id, false) + } + } + + const now = Date.now() + if (ln.response) { + if (model === thinking_model) { + let patchedThoughts = ln.response + const thinkTagRx = /([\s\S]*?)<\/think>/g + patchedThoughts = patchedThoughts.replace(thinkTagRx, (match, p1) => p1.trim().length > 0 ? '`Thinking...`' + p1 + '`Finished thinking`' : '') + patchedThoughts = patchedThoughts.replace(//g, '`Thinking...`') + patchedThoughts = patchedThoughts.replace(/<\/think>/g, '`Finished thinking`') + thoughts += patchedThoughts + fullResponse += patchedThoughts + } else { + fullResponse += ln.response + } + if (now - lastUpdate >= 1000) { + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + thoughts, + { parse_mode: 'Markdown' } + ) + lastUpdate = now + } } } } return { - "success": true, - "response": fullResponse, + success: true, + response: fullResponse, } } catch (error: any) { let shouldPullModel = false - - if (error.response?.data?.error) { - if (error.response.data.error.includes(`model '${model}' not found`) || error.status === 404) { + if (error.response) { + const errData = error.response.data?.error + const errStatus = error.response.status + if (errData && (errData.includes(`model '${model}' not found`) || errStatus === 404)) { shouldPullModel = true } else { - console.error("[✨ AI | !] Error zone 1:", error.response.data.error) - return { - "success": false, - "error": error.response.data.error, - } + console.error("[✨ AI | !] Error zone 1:", errData) + return { success: false, error: errData } } - } else if (error.status === 404) { - shouldPullModel = true + } else if (error.request) { + console.error("[✨ AI | !] No response received:", error.request) + return { success: false, error: "No response received from server" } + } else { + console.error("[✨ AI | !] Error zone 3:", error.message) + return { success: false, error: error.message } } if (shouldPullModel) { ctx.telegram.editMessageText(ctx.chat.id, replyGenerating.message_id, undefined, `🔄 Pulling ${model} from ollama...\n\nThis may take a few minutes...`) console.log(`[✨ AI | i] Pulling ${model} from ollama...`) - let pullModelStream: any - try { - pullModelStream = await axios.post(`${process.env.ollamaApi}/api/pull`, { - model: model, - stream: false, - timeout: process.env.ollamaApiTimeout || 10000, - }) + await axios.post( + `${process.env.ollamaApi}/api/pull`, + { + model, + stream: false, + timeout: process.env.ollamaApiTimeout || 10000, + } + ) } catch (e: any) { - console.error("[✨ AI | !] Something went wrong:", e.response.data.error) - return { - "success": false, - "error": `❌ Something went wrong while pulling ${model}, please try your command again!`, + if (e.response) { + console.error("[✨ AI | !] Something went wrong:", e.response.data?.error) + return { + success: false, + error: `❌ Something went wrong while pulling ${model}, please try your command again!`, + } + } else if (e.request) { + console.error("[✨ AI | !] No response received while pulling:", e.request) + return { + success: false, + error: `❌ No response received while pulling ${model}, please try again!`, + } + } else { + console.error("[✨ AI | !] Error while pulling:", e.message) + return { + success: false, + error: `❌ Error while pulling ${model}: ${e.message}`, + } } } - console.log(`[✨ AI | i] ${model} pulled successfully`) return { - "success": true, - "response": `✅ Pulled ${model} successfully, please retry the command.`, + success: true, + response: `✅ Pulled ${model} successfully, please retry the command.`, } } - - if (error.response) { - console.error("[✨ AI | !] Error zone 2:", error.response) - return { - "success": false, - "error": error.response, - } - } - - if (error.statusText) { - console.error("[✨ AI | !] Error zone 3:", error.statusText) - return { - "success": false, - "error": error.statusText, - } - } - - return { - "success": false, - "error": "An unexpected error occurred", - } } } @@ -224,19 +218,16 @@ export default (bot: Telegraf) => { const botName = bot.botInfo?.first_name && bot.botInfo?.last_name ? `${bot.botInfo.first_name} ${bot.botInfo.last_name}` : "Kowalski" bot.command(["ask", "think"], spamwatchMiddleware, async (ctx) => { - if (!ctx.message || !('text' in ctx.message)) return; + if (!ctx.message || !('text' in ctx.message)) return const isAsk = ctx.message.text.startsWith("/ask") const model = isAsk ? flash_model : thinking_model - const textCtx = ctx as TextContext; + const textCtx = ctx as TextContext const reply_to_message_id = replyToMessageId(textCtx) const Strings = getStrings(languageCode(textCtx)) const message = textCtx.message.text const author = ("@" + ctx.from?.username) || ctx.from?.first_name - logger.logCmdStart( - author, - model === flash_model ? "ask" : "think" - ) + logger.logCmdStart(author, model === flash_model ? "ask" : "think") if (!process.env.ollamaApi) { await ctx.reply(Strings.aiDisabled, { @@ -251,7 +242,7 @@ export default (bot: Telegraf) => { ...({ reply_to_message_id }) }) - const fixedMsg = message.replace(/\/ask /, "").replace(/\/think /, "") + const fixedMsg = message.replace(/\/(ask|think) /, "") if (fixedMsg.length < 1) { await ctx.reply(Strings.askNoMessage, { parse_mode: 'Markdown', @@ -273,8 +264,8 @@ ${fixedMsg}`) const aiResponse = await getResponse(prompt, textCtx, replyGenerating, model) if (!aiResponse) return + if (!ctx.chat) return if (aiResponse.success && aiResponse.response) { - if (!ctx.chat) return await rateLimiter.editMessageWithRetry( ctx, ctx.chat.id, @@ -282,16 +273,15 @@ ${fixedMsg}`) aiResponse.response, { parse_mode: 'Markdown' } ) - } else { - if (!ctx.chat) return - const error = Strings.unexpectedErr.replace("{error}", aiResponse.error) - await rateLimiter.editMessageWithRetry( - ctx, - ctx.chat.id, - replyGenerating.message_id, - error, - { parse_mode: 'Markdown' } - ) + return } + const error = Strings.unexpectedErr.replace("{error}", aiResponse.error) + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + error, + { parse_mode: 'Markdown' } + ) }) } \ No newline at end of file -- 2.47.2 From 276d053ed59b95e342c264449061cebf6ab7330d Mon Sep 17 00:00:00 2001 From: Aidan Date: Sat, 28 Jun 2025 15:19:51 -0400 Subject: [PATCH 12/12] cleanup, logging of ratelimit --- src/utils/rate-limiter.ts | 209 ++++++++++++++++++++------------------ 1 file changed, 110 insertions(+), 99 deletions(-) diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts index 4b4a324..777bb4f 100644 --- a/src/utils/rate-limiter.ts +++ b/src/utils/rate-limiter.ts @@ -45,15 +45,85 @@ class RateLimiter { return `${chatId}:${messageId}` } - private async waitForRateLimit(): Promise { - if (this.isRateLimited) { - const now = Date.now() - if (now < this.rateLimitEndTime) { - const waitTime = this.rateLimitEndTime - now - await new Promise(resolve => setTimeout(resolve, waitTime)) - } - this.isRateLimited = false + private async waitForRateLimit(chatId: number, messageId: number): Promise { + if (!this.isRateLimited) return + console.log(`[✨ AI | RATELIMIT] [${chatId}:${messageId}] Ratelimited, waiting for end of ${this.rateLimitEndTime - Date.now()}ms`) + const now = Date.now() + if (now < this.rateLimitEndTime) { + await new Promise(resolve => setTimeout(resolve, this.rateLimitEndTime - now)) } + this.isRateLimited = false + } + + private chunkText(text: string): string[] { + const chunks: string[] = [] + let currentChunk = '' + let currentLength = 0 + const lines = text.split('\n') + for (const line of lines) { + if (currentLength + line.length + 1 > this.max_msg_length) { + if (currentChunk) { + chunks.push(currentChunk) + currentChunk = '' + currentLength = 0 + } + if (line.length > this.max_msg_length) { + for (let i = 0; i < line.length; i += this.max_msg_length) { + chunks.push(line.substring(i, i + this.max_msg_length)) + } + } else { + currentChunk = line + currentLength = line.length + } + } else { + if (currentChunk) { + currentChunk += '\n' + currentLength++ + } + currentChunk += line + currentLength += line.length + } + } + if (currentChunk) { + chunks.push(currentChunk) + } + return chunks + } + + private handleTelegramError(error: unknown, messageKey: string, options: any, ctx: Context, chatId: number, messageId: number): boolean { + if (!isTelegramError(error)) return false + if (error.response.error_code === 429) { + const retryAfter = error.response.parameters?.retry_after || 1 + this.isRateLimited = true + this.rateLimitEndTime = Date.now() + (retryAfter * 1000) + const existingTimeout = this.updateQueue.get(messageKey) + if (existingTimeout) clearTimeout(existingTimeout) + const timeout = setTimeout(() => { + this.processUpdate(ctx, chatId, messageId, options) + }, retryAfter * 1000) + this.updateQueue.set(messageKey, timeout) + return true + } + if (error.response.error_code === 400) { + if (error.response.description?.includes("can't parse entities") || error.response.description?.includes("MESSAGE_TOO_LONG")) { + const plainOptions = { ...options, parse_mode: undefined } + this.processUpdate(ctx, chatId, messageId, plainOptions) + return true + } + if (error.response.description?.includes("message is not modified")) { + this.pendingUpdates.delete(messageKey) + this.updateQueue.delete(messageKey) + return true + } + logger.logError(error) + this.pendingUpdates.delete(messageKey) + this.updateQueue.delete(messageKey) + return true + } + logger.logError(error) + this.pendingUpdates.delete(messageKey) + this.updateQueue.delete(messageKey) + return true } private async processUpdate( @@ -68,81 +138,45 @@ class RateLimiter { const now = Date.now() const timeSinceLastEdit = now - this.lastEditTime - - await this.waitForRateLimit() + await this.waitForRateLimit(chatId, messageId) if (timeSinceLastEdit < this.minInterval) { const existingTimeout = this.updateQueue.get(messageKey) - if (existingTimeout) { - clearTimeout(existingTimeout) - } - + if (existingTimeout) clearTimeout(existingTimeout) const timeout = setTimeout(() => { this.processUpdate(ctx, chatId, messageId, options) }, this.minInterval - timeSinceLastEdit) - this.updateQueue.set(messageKey, timeout) return } try { if (latestText.length > this.max_msg_length) { - const chunks: string[] = [] - let currentChunk = '' - let currentLength = 0 - - // Split text into chunks while preserving markdown formatting - const lines = latestText.split('\n') - for (const line of lines) { - if (currentLength + line.length + 1 > this.max_msg_length) { - if (currentChunk) { - chunks.push(currentChunk) - currentChunk = '' - currentLength = 0 - } - // if a single line is too long, split - if (line.length > this.max_msg_length) { - for (let i = 0; i < line.length; i += this.max_msg_length) { - chunks.push(line.substring(i, i + this.max_msg_length)) - } - } else { - currentChunk = line - currentLength = line.length - } - } else { - if (currentChunk) { - currentChunk += '\n' - currentLength++ - } - currentChunk += line - currentLength += line.length - } - } - if (currentChunk) { - chunks.push(currentChunk) - } - + const chunks = this.chunkText(latestText) const firstChunk = chunks[0] logger.logChunk(chatId, messageId, firstChunk) - try { await ctx.telegram.editMessageText(chatId, messageId, undefined, firstChunk, options) - } catch (error: any) { - if (!error.response?.description?.includes("message is not modified")) { + } catch (error: unknown) { + if ( + isTelegramError(error) && + !error.response.description?.includes("message is not modified") + ) { throw error } } - for (let i = 1; i < chunks.length; i++) { const chunk = chunks[i] const overflowMessageId = this.overflowMessages.get(messageKey) - if (overflowMessageId) { try { await ctx.telegram.editMessageText(chatId, overflowMessageId, undefined, chunk, options) logger.logChunk(chatId, overflowMessageId, chunk, true) - } catch (error: any) { - if (!error.response?.description?.includes("message is not modified")) { + } catch (error: unknown) { + if ( + isTelegramError(error) && + !error.response.description?.includes("message is not modified") + ) { throw error } } @@ -155,7 +189,6 @@ class RateLimiter { this.overflowMessages.set(messageKey, newMessage.message_id) } } - this.pendingUpdates.set(messageKey, firstChunk) if (chunks.length > 1) { this.pendingUpdates.set( @@ -164,54 +197,23 @@ class RateLimiter { ) } } else { - const sanitizedText = latestText - logger.logChunk(chatId, messageId, sanitizedText) - + logger.logChunk(chatId, messageId, latestText) try { - await ctx.telegram.editMessageText(chatId, messageId, undefined, sanitizedText, options) - } catch (error: any) { - if (!error.response?.description?.includes("message is not modified")) { + await ctx.telegram.editMessageText(chatId, messageId, undefined, latestText, options) + } catch (error: unknown) { + if ( + isTelegramError(error) && + !error.response.description?.includes("message is not modified") + ) { throw error } } this.pendingUpdates.delete(messageKey) } - this.lastEditTime = Date.now() this.updateQueue.delete(messageKey) - } catch (error: any) { - if (error.response?.error_code === 429) { - const retryAfter = error.response.parameters?.retry_after || 1 - this.isRateLimited = true - this.rateLimitEndTime = Date.now() + (retryAfter * 1000) - - const existingTimeout = this.updateQueue.get(messageKey) - if (existingTimeout) { - clearTimeout(existingTimeout) - } - - const timeout = setTimeout(() => { - this.processUpdate(ctx, chatId, messageId, options) - }, retryAfter * 1000) - - this.updateQueue.set(messageKey, timeout) - } else if (error.response?.error_code === 400) { - if (error.response?.description?.includes("can't parse entities")) { - // try again with plain text - const plainOptions = { ...options, parse_mode: undefined } - await this.processUpdate(ctx, chatId, messageId, plainOptions) - } else if (error.response?.description?.includes("MESSAGE_TOO_LONG")) { - const plainOptions = { ...options, parse_mode: undefined } - await this.processUpdate(ctx, chatId, messageId, plainOptions) - } else if (error.response?.description?.includes("message is not modified")) { - this.pendingUpdates.delete(messageKey) - this.updateQueue.delete(messageKey) - } else { - logger.logError(error) - this.pendingUpdates.delete(messageKey) - this.updateQueue.delete(messageKey) - } - } else { + } catch (error: unknown) { + if (!this.handleTelegramError(error, messageKey, options, ctx, chatId, messageId)) { logger.logError(error) this.pendingUpdates.delete(messageKey) this.updateQueue.delete(messageKey) @@ -232,4 +234,13 @@ class RateLimiter { } } -export const rateLimiter = new RateLimiter() \ No newline at end of file +export const rateLimiter = new RateLimiter() + +function isTelegramError(error: unknown): error is { response: { description?: string, error_code?: number, parameters?: { retry_after?: number } } } { + return ( + typeof error === "object" && + error !== null && + "response" in error && + typeof (error as any).response === "object" + ) +} \ No newline at end of file -- 2.47.2