diff --git a/.dockerignore b/.dockerignore index 9fe19f3..e123d1a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,5 @@ npm-debug.log .env *.md !README.md -ollama/ \ No newline at end of file +ollama/ +db/ \ No newline at end of file diff --git a/.env.example b/.env.example index f59b14a..2aa13c0 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ ollamaEnabled = false # flashModel = "gemma3:4b" # thinkingModel = "qwen3:4b" +# database +databaseUrl = "postgres://kowalski:kowalski@localhost:5432/kowalski" + # misc (botAdmins isnt a array here!) maxRetries = 9999 botAdmins = 00000000, 00000000, 00000000 diff --git a/.gitignore b/.gitignore index 278fef8..dbea724 100644 --- a/.gitignore +++ b/.gitignore @@ -150,4 +150,7 @@ bun.lock* ollama/ # Docker -docker-compose.yml \ No newline at end of file +docker-compose.yml + +# postgres +db/ \ No newline at end of file diff --git a/README.md b/README.md index 7912e3d..e035285 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Kowalski is a a simple Telegram bot made in Node.js. - A Telegram bot (create one at [@BotFather](https://t.me/botfather)) - FFmpeg (only for the `/yt` command) - Docker and Docker Compose (only required for Docker setup) +- Postgres ### AI Requirements @@ -116,6 +117,7 @@ If you prefer to use Docker directly, you can use these instructions instead. - **handlerTimeout** (optional): How long handlers will wait before timing out. Set this high if using large AI models. - **flashModel** (optional): Which model will be used for /ask - **thinkingModel** (optional): Which model will be used for /think +- **databaseUrl**: Database server configuration (see `.env.example`) - **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. @@ -149,3 +151,5 @@ Made with [contrib.rocks](https://contrib.rocks). ## About/License BSD-3-Clause - 2024 Lucas Gabriel (lucmsilva). + +Featuring some components under Unlicense. diff --git a/docker-compose.yml.ai.example b/docker-compose.yml.ai.example index 2c516f7..2a5c7e9 100644 --- a/docker-compose.yml.ai.example +++ b/docker-compose.yml.ai.example @@ -12,4 +12,16 @@ services: container_name: kowalski-ollama restart: unless-stopped volumes: - - ./ollama:/root/.ollama \ No newline at end of file + - ./ollama:/root/.ollama + postgres: + image: postgres:17 + container_name: kowalski-postgres + restart: unless-stopped + ports: + - 5433:5432 + volumes: + - ./db:/var/lib/postgresql/data + environment: + - POSTGRES_USER=kowalski + - POSTGRES_PASSWORD=kowalski + - POSTGRES_DB=kowalski \ No newline at end of file diff --git a/docker-compose.yml.example b/docker-compose.yml.example index f3bb819..65a2206 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -6,4 +6,16 @@ services: volumes: - ./.env:/usr/src/app/.env:ro environment: - - NODE_ENV=production \ No newline at end of file + - NODE_ENV=production + postgres: + image: postgres:17 + container_name: kowalski-postgres + restart: unless-stopped + ports: + - 5433:5432 + volumes: + - ./db:/var/lib/postgresql/data + environment: + - POSTGRES_USER=kowalski + - POSTGRES_PASSWORD=kowalski + - POSTGRES_DB=kowalski \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..6766ff6 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,11 @@ +import 'dotenv/config'; +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + out: './drizzle', + schema: './src/db/schema.ts', + dialect: 'postgresql', + dbCredentials: { + url: process.env.databaseUrl!, + }, +}); diff --git a/package.json b/package.json index 5c53440..7172307 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,24 @@ { "scripts": { - "start": "nodemon src/bot.ts" + "start": "nodemon src/bot.ts", + "docs": "bunx typedoc", + "serve:docs": "bun run serve-docs.ts" }, "dependencies": { "@dotenvx/dotenvx": "^1.45.1", "@types/bun": "^1.2.17", "axios": "^1.10.0", + "dotenv": "^17.0.0", + "drizzle-orm": "^0.44.2", "node-html-parser": "^7.0.1", "nodemon": "^3.1.10", + "pg": "^8.16.3", "telegraf": "^4.16.3", "youtube-url": "^0.5.0" + }, + "devDependencies": { + "@types/pg": "^8.15.4", + "drizzle-kit": "^0.31.4", + "tsx": "^4.20.3" } } diff --git a/src/bot.ts b/src/bot.ts index 04d2c97..ba9176f 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,90 +1,129 @@ import { Telegraf } from 'telegraf'; import path from 'path'; import fs from 'fs'; -import { isOnSpamWatch } from './spamwatch/spamwatch'; +import { isSpamwatchConnected } from './spamwatch/spamwatch'; import '@dotenvx/dotenvx'; +import 'dotenv/config'; import './plugins/ytDlpWrapper'; import { preChecks } from './commands/ai'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Client } from 'pg'; +import * as schema from './db/schema'; +import { ensureUserInDb } from './utils/ensure-user'; +import { getSpamwatchBlockedCount } from './spamwatch/spamwatch'; -// Ensures bot token is set, and not default value -if (!process.env.botToken || process.env.botToken === 'InsertYourBotTokenHere') { - console.error('Bot token is not set. Please set the bot token in the .env file.') - process.exit(1) -} - -// Detect AI and run pre-checks -if (process.env.ollamaEnabled === "true") { - if (!(await preChecks())) { - process.exit(1) +(async function main() { + const { botToken, handlerTimeout, maxRetries, databaseUrl, ollamaEnabled } = process.env; + if (!botToken || botToken === 'InsertYourBotTokenHere') { + console.error('Bot token is not set. Please set the bot token in the .env file.'); + 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; - -const loadCommands = () => { - const commandsPath = path.join(__dirname, 'commands'); - - try { - const files = fs.readdirSync(commandsPath) - .filter(file => file.endsWith('.ts') || file.endsWith('.js')); - - files.forEach((file) => { - try { - const commandPath = path.join(commandsPath, file); - const command = require(commandPath).default || require(commandPath); - if (typeof command === 'function') { - command(bot, isOnSpamWatch); - } - } catch (error) { - console.error(`Failed to load command file ${file}: ${error.message}`); - } - }); - } catch (error) { - console.error(`Failed to read commands directory: ${error.message}`); - } -}; - -const startBot = async () => { - const botInfo = await bot.telegram.getMe(); - console.log(`${botInfo.first_name} is running...`); - try { - await bot.launch(); - restartCount = 0; - } catch (error) { - console.error('Failed to start bot:', error.message); - if (restartCount < Number(maxRetries)) { - restartCount++; - console.log(`Retrying to start bot... Attempt ${restartCount}`); - setTimeout(startBot, 5000); - } else { - console.error('Maximum retry attempts reached. Exiting.'); + if (ollamaEnabled === "true") { + if (!(await preChecks())) { process.exit(1); } } -}; -const handleShutdown = (signal) => { - console.log(`Received ${signal}. Stopping bot...`); - bot.stop(signal); - process.exit(0); -}; + const client = new Client({ connectionString: databaseUrl }); + await client.connect(); + const db = drizzle(client, { schema }); -process.once('SIGINT', () => handleShutdown('SIGINT')); -process.once('SIGTERM', () => handleShutdown('SIGTERM')); + const bot = new Telegraf( + botToken, + { handlerTimeout: Number(handlerTimeout) || 600_000 } + ); + const maxRetriesNum = Number(maxRetries) || 5; + let restartCount = 0; -process.on('uncaughtException', (error) => { - console.error('Uncaught Exception:', error.message); - console.error(error.stack); -}); + bot.use(async (ctx, next) => { + await ensureUserInDb(ctx, db); + return next(); + }); -process.on('unhandledRejection', (reason, promise) => { - console.error('Unhandled Rejection at:', promise, 'reason:', reason); -}); + function loadCommands() { + const commandsPath = path.join(__dirname, 'commands'); + let loadedCount = 0; + try { + const files = fs.readdirSync(commandsPath) + .filter(file => file.endsWith('.ts') || file.endsWith('.js')); + files.forEach((file) => { + try { + const commandPath = path.join(commandsPath, file); + const command = require(commandPath).default || require(commandPath); + if (typeof command === 'function') { + command(bot, db); + loadedCount++; + } + } catch (error) { + console.error(`Failed to load command file ${file}: ${error.message}`); + } + }); + console.log(`[🤖 BOT] Loaded ${loadedCount} commands.\n`); + } catch (error) { + console.error(`Failed to read commands directory: ${error.message}`); + } + } -loadCommands(); -startBot(); + async function startBot() { + try { + const botInfo = await bot.telegram.getMe(); + console.log(`${botInfo.first_name} is running...`); + await bot.launch(); + restartCount = 0; + } catch (error) { + console.error('Failed to start bot:', error.message); + if (restartCount < maxRetriesNum) { + restartCount++; + console.log(`Retrying to start bot... Attempt ${restartCount}`); + setTimeout(startBot, 5000); + } else { + console.error('Maximum retry attempts reached. Exiting.'); + process.exit(1); + } + } + } + + function handleShutdown(signal: string) { + console.log(`Received ${signal}. Stopping bot...`); + bot.stop(signal); + process.exit(0); + } + + process.once('SIGINT', () => handleShutdown('SIGINT')); + process.once('SIGTERM', () => handleShutdown('SIGTERM')); + + process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error.message); + console.error(error.stack); + }); + + process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + }); + + async function testDbConnection() { + try { + await db.query.usersTable.findMany({ limit: 1 }); + const users = await db.query.usersTable.findMany({}); + const userCount = users.length; + console.log(`[💽 DB] Connected [${userCount} users]`); + } catch (err) { + console.error('[💽 DB] Failed to connect:', err); + process.exit(1); + } + } + + await testDbConnection(); + + if (isSpamwatchConnected()) { + const blockedCount = getSpamwatchBlockedCount(); + // the 3 spaces are intentional + console.log(`[🛡️ SW] Connected [${blockedCount} blocked]`); + } else { + console.log('[🛡️ SW] Not connected or blocklist empty'); + } + + loadCommands(); + startBot(); +})(); diff --git a/src/commands/ai.ts b/src/commands/ai.ts index dfec202..4431f56 100644 --- a/src/commands/ai.ts +++ b/src/commands/ai.ts @@ -38,6 +38,9 @@ import { languageCode } from "../utils/language-code" import axios from "axios" import { rateLimiter } from "../utils/rate-limiter" import { logger } from "../utils/log" +import { ensureUserInDb } from "../utils/ensure-user" +import * as schema from '../db/schema' +import type { NodePgDatabase } from "drizzle-orm/node-postgres" const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch) export const flash_model = process.env.flashModel || "gemma3:4b" @@ -45,6 +48,94 @@ export const thinking_model = process.env.thinkingModel || "qwen3:4b" type TextContext = Context & { message: Message.TextMessage } +type User = typeof schema.usersTable.$inferSelect + +interface ModelInfo { + name: string; + label: string; + descriptionEn: string; + descriptionPt: string; + models: Array<{ + name: string; + label: string; + parameterSize: string; + }>; +} + +interface OllamaResponse { + response: string; +} + +export const models: ModelInfo[] = [ + { + name: 'gemma3n', + label: 'Gemma3n', + descriptionEn: 'Gemma3n is a family of open, light on-device models for general tasks.', + descriptionPt: 'Gemma3n é uma família de modelos abertos, leves e para dispositivos locais, para tarefas gerais.', + models: [ + { name: 'gemma3n:e2b', label: 'Gemma3n e2b', parameterSize: '2B' }, + { name: 'gemma3n:e4b', label: 'Gemma3n e4b', parameterSize: '4B' }, + ] + }, + { + name: 'gemma3-abliterated', + label: 'Gemma3 Uncensored', + descriptionEn: 'Gemma3-abliterated is a family of open, uncensored models for general tasks.', + descriptionPt: 'Gemma3-abliterated é uma família de modelos abertos, não censurados, para tarefas gerais.', + models: [ + { name: 'huihui_ai/gemma3-abliterated:1b', label: 'Gemma3-abliterated 1B', parameterSize: '1b' }, + { name: 'huihui_ai/gemma3-abliterated:4b', label: 'Gemma3-abliterated 4B', parameterSize: '4b' }, + ] + }, + { + name: 'qwen3', + label: 'Qwen3', + descriptionEn: 'Qwen3 is a multilingual reasoning model series.', + descriptionPt: 'Qwen3 é uma série de modelos multilingues.', + models: [ + { name: 'qwen3:4b', label: 'Qwen3 4B', parameterSize: '4B' }, + ] + }, + { + name: 'deepseek', + label: 'DeepSeek', + descriptionEn: 'DeepSeek is a research model for reasoning tasks.', + descriptionPt: 'DeepSeek é um modelo de pesquisa para tarefas de raciocínio.', + models: [ + { name: 'deepseek-r1:1.5b', label: 'DeepSeek 1.5B', parameterSize: '1.5B' }, + { name: 'huihui_ai/deepseek-r1-abliterated:1.5b', label: 'DeepSeek Uncensored 1.5B', parameterSize: '1.5B' }, + ] + } +]; + +const enSystemPrompt = `You are a plaintext-only, helpful assistant called {botName}. +Current Date/Time (UTC): {date} + +--- + +Respond to the user's message: +{message}` + +const ptSystemPrompt = `Você é um assistente de texto puro e útil chamado {botName}. +Data/Hora atual (UTC): {date} + +--- + +Responda à mensagem do usuário: +{message}` + +async function usingSystemPrompt(ctx: TextContext, db: NodePgDatabase, botName: string): Promise { + const user = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(ctx.from!.id)), limit: 1 }); + if (user.length === 0) await ensureUserInDb(ctx, db); + const userData = user[0]; + const lang = userData?.languageCode || "en"; + const utcDate = new Date().toISOString(); + const prompt = lang === "pt" + ? ptSystemPrompt.replace("{botName}", botName).replace("{date}", utcDate).replace("{message}", ctx.message.text) + : enSystemPrompt.replace("{botName}", botName).replace("{date}", utcDate).replace("{message}", ctx.message.text); + return prompt; +} + export function sanitizeForJson(text: string): string { return text .replace(/\\/g, '\\\\') @@ -69,23 +160,50 @@ export async function preChecks() { } checked++; } - console.log(`[✨ AI] Pre-checks passed [${checked}/${envs.length}]\n`) + + const ollamaApi = process.env.ollamaApi + if (!ollamaApi) { + console.error("[✨ AI | !] ❌ ollamaApi not set!") + return false + } + let ollamaOk = false + for (let i = 0; i < 10; i++) { + try { + const res = await axios.get(ollamaApi, { timeout: 2000 }) + if (res && res.data && typeof res.data === 'object' && 'ollama' in res.data) { + ollamaOk = true + break + } + if (res && res.status === 200) { + ollamaOk = true + break + } + } catch (err) { + await new Promise(resolve => setTimeout(resolve, 1000)) + } + } + if (!ollamaOk) { + console.error("[✨ AI | !] ❌ Ollama API is not responding at ", ollamaApi) + return false + } + checked++; + console.log(`[✨ AI] Pre-checks passed [${checked}/${envs.length + 1}]`) return true } -function isAxiosError(error: unknown): error is { response?: { data?: { error?: string }, status?: number }, request?: unknown, message?: string } { +function isAxiosError(error: unknown): error is { response?: { data?: { error?: string }, status?: number, statusText?: string }, request?: unknown, message?: string } { return typeof error === 'object' && error !== null && ( 'response' in error || 'request' in error || 'message' in error - ) + ); } function extractAxiosErrorMessage(error: unknown): string { if (isAxiosError(error)) { - const err = error as Record; + const err = error as { response?: { data?: { error?: string }, status?: number, statusText?: string }, request?: unknown, message?: string }; if (err.response && typeof err.response === 'object') { - const resp = err.response as Record; + const resp = err.response; if (resp.data && typeof resp.data === 'object' && 'error' in resp.data) { - return String((resp.data as Record).error); + return String(resp.data.error); } if ('status' in resp && 'statusText' in resp) { return `HTTP ${resp.status}: ${resp.statusText}`; @@ -102,71 +220,68 @@ function extractAxiosErrorMessage(error: unknown): string { return 'An unexpected error occurred.'; } -async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message, model: string) { - const Strings = getStrings(languageCode(ctx)) - +async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message, model: string, aiTemperature: number): Promise<{ success: boolean; response?: string; error?: string }> { + 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( + const aiResponse = await axios.post( `${process.env.ollamaApi}/api/generate`, { model, prompt, stream: true, + options: { + temperature: aiTemperature + } }, { responseType: "stream", } - ) - - let fullResponse = "" - let thoughts = "" - let lastUpdate = Date.now() - - const stream = aiResponse.data + ); + let fullResponse = ""; + let thoughts = ""; + let lastUpdate = Date.now(); + const stream: NodeJS.ReadableStream = aiResponse.data as any; for await (const chunk of stream) { - const lines = chunk.toString().split('\n') + const lines = chunk.toString().split('\n'); for (const line of lines) { - if (!line.trim()) continue - let ln + if (!line.trim()) continue; + let ln: OllamaResponse; try { - ln = JSON.parse(line) + ln = JSON.parse(line); } catch (e) { - console.error("[✨ AI | !] Error parsing chunk:", e) - continue + console.error("[✨ AI | !] Error parsing chunk:", e); + continue; } - - if (model === thinking_model) { + if (model === thinking_model && ln.response) { if (ln.response.includes('')) { - const thinkMatch = ln.response.match(/([\s\S]*?)<\/think>/) + const thinkMatch = ln.response.match(/([\s\S]*?)<\/think>/); if (thinkMatch && thinkMatch[1].trim().length > 0) { - logger.logThinking(ctx.chat.id, replyGenerating.message_id, true) + logger.logThinking(ctx.chat.id, replyGenerating.message_id, true); } else if (!thinkMatch) { - logger.logThinking(ctx.chat.id, replyGenerating.message_id, true) + logger.logThinking(ctx.chat.id, replyGenerating.message_id, true); } } else if (ln.response.includes('')) { - logger.logThinking(ctx.chat.id, replyGenerating.message_id, false) + logger.logThinking(ctx.chat.id, replyGenerating.message_id, false); } } - - const now = Date.now() + 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 + 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 + fullResponse += ln.response; } if (now - lastUpdate >= 1000) { await rateLimiter.editMessageWithRetry( @@ -175,67 +290,104 @@ async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Me replyGenerating.message_id, thoughts, { parse_mode: 'Markdown' } - ) - lastUpdate = now + ); + lastUpdate = now; } } } } - return { success: true, response: fullResponse, - } + }; } catch (error: unknown) { - const errorMsg = extractAxiosErrorMessage(error) - console.error("[✨ AI | !] Error:", errorMsg) - - // model not found or 404 + const errorMsg = extractAxiosErrorMessage(error); + console.error("[✨ AI | !] Error:", errorMsg); if (isAxiosError(error) && error.response && typeof error.response === 'object') { - const resp = error.response as Record; - const errData = resp.data && typeof resp.data === 'object' && 'error' in resp.data ? (resp.data as Record).error : undefined; + const resp = error.response as { data?: { error?: string }, status?: number }; + const errData = resp.data && typeof resp.data === 'object' && 'error' in resp.data ? (resp.data as { error?: string }).error : undefined; const errStatus = 'status' in resp ? resp.status : undefined; if ((typeof errData === 'string' && errData.includes(`model '${model}' not found`)) || errStatus === 404) { - ctx.telegram.editMessageText( - ctx.chat.id, + await ctx.telegram.editMessageText( + ctx.chat!.id, replyGenerating.message_id, undefined, - `🔄 *Pulling ${model} from Ollama...*\n\nThis may take a few minutes...`, + Strings.ai.pulling.replace("{model}", model), { parse_mode: 'Markdown' } - ) - console.log(`[✨ AI | i] Pulling ${model} from ollama...`) + ); + console.log(`[✨ AI | i] Pulling ${model} from ollama...`); try { await axios.post( `${process.env.ollamaApi}/api/pull`, { model, stream: false, - timeout: process.env.ollamaApiTimeout || 10000, + timeout: Number(process.env.ollamaApiTimeout) || 10000, } - ) + ); } catch (e: unknown) { - const pullMsg = extractAxiosErrorMessage(e) - console.error("[✨ AI | !] Pull error:", pullMsg) + const pullMsg = extractAxiosErrorMessage(e); + console.error("[✨ AI | !] Pull error:", pullMsg); return { success: false, error: `❌ Something went wrong while pulling ${model}: ${pullMsg}`, - } + }; } - console.log(`[✨ AI | i] ${model} pulled successfully`) + console.log(`[✨ AI | i] ${model} pulled successfully`); return { success: true, response: `✅ Pulled ${model} successfully, please retry the command.`, - } + }; } } return { success: false, error: errorMsg, - } + }; } } -export default (bot: Telegraf) => { +async function handleAiReply(ctx: TextContext, db: NodePgDatabase, model: string, prompt: string, replyGenerating: Message, aiTemperature: number) { + const Strings = getStrings(languageCode(ctx)); + const aiResponse = await getResponse(prompt, ctx, replyGenerating, model, aiTemperature); + if (!aiResponse) return; + if (!ctx.chat) return; + if (aiResponse.success && aiResponse.response) { + const modelHeader = `🤖 *${model}* | 🌡️ *${aiTemperature}*\n\n`; + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + aiResponse.response, + { 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' } + ); +} + +async function getUserWithStringsAndModel(ctx: Context, db: NodePgDatabase): Promise<{ user: User; Strings: ReturnType; languageCode: string; customAiModel: string; aiTemperature: number }> { + const userArr = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(ctx.from!.id)), limit: 1 }); + let user = userArr[0]; + if (!user) { + await ensureUserInDb(ctx, db); + const newUserArr = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(ctx.from!.id)), limit: 1 }); + user = newUserArr[0]; + const Strings = getStrings(user.languageCode); + return { user, Strings, languageCode: user.languageCode, customAiModel: user.customAiModel, aiTemperature: user.aiTemperature }; + } + const Strings = getStrings(user.languageCode); + return { user, Strings, languageCode: user.languageCode, customAiModel: user.customAiModel, aiTemperature: user.aiTemperature }; +} + +export default (bot: Telegraf, db: NodePgDatabase) => { 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) => { @@ -244,65 +396,75 @@ export default (bot: Telegraf) => { const model = isAsk ? flash_model : thinking_model const textCtx = ctx as TextContext const reply_to_message_id = replyToMessageId(textCtx) - const Strings = getStrings(languageCode(textCtx)) + const { Strings, aiTemperature } = await getUserWithStringsAndModel(textCtx, db) const message = textCtx.message.text const author = ("@" + ctx.from?.username) || ctx.from?.first_name logger.logCmdStart(author, model === flash_model ? "ask" : "think") if (!process.env.ollamaApi) { - await ctx.reply(Strings.aiDisabled, { + await ctx.reply(Strings.ai.disabled, { parse_mode: 'Markdown', ...({ reply_to_message_id }) }) return } - const replyGenerating = await ctx.reply(Strings.askGenerating.replace("{model}", model), { + const fixedMsg = message.replace(/^\/(ask|think)(@\w+)?\s*/, "").trim() + if (fixedMsg.length < 1) { + await ctx.reply(Strings.ai.askNoMessage, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + return + } + + const replyGenerating = await ctx.reply(Strings.ai.askGenerating.replace("{model}", model), { parse_mode: 'Markdown', ...({ reply_to_message_id }) }) - const fixedMsg = message.replace(/\/(ask|think) /, "") - if (fixedMsg.length < 1) { - await ctx.reply(Strings.askNoMessage, { + logger.logPrompt(fixedMsg) + + const prompt = sanitizeForJson(await usingSystemPrompt(textCtx, db, botName)) + await handleAiReply(textCtx, db, model, prompt, replyGenerating, aiTemperature) + }) + + bot.command(["ai"], 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, customAiModel, aiTemperature } = await getUserWithStringsAndModel(textCtx, db) + const message = textCtx.message.text + const author = ("@" + ctx.from?.username) || ctx.from?.first_name + + logger.logCmdStart(author, "ask") + + if (!process.env.ollamaApi) { + await ctx.reply(Strings.ai.disabled, { parse_mode: 'Markdown', ...({ reply_to_message_id }) }) return } - logger.logPrompt(fixedMsg) - - const prompt = sanitizeForJson( -`You are a plaintext-only, helpful assistant called ${botName}. -Current Date/Time (UTC): ${new Date().toLocaleString()} - ---- - -Respond to the user's message: -${fixedMsg}`) - const aiResponse = await getResponse(prompt, textCtx, replyGenerating, model) - if (!aiResponse) return - - if (!ctx.chat) return - if (aiResponse.success && aiResponse.response) { - await rateLimiter.editMessageWithRetry( - ctx, - ctx.chat.id, - replyGenerating.message_id, - aiResponse.response, - { parse_mode: 'Markdown' } - ) + const fixedMsg = message.replace(/^\/ai(@\w+)?\s*/, "").trim() + if (fixedMsg.length < 1) { + await ctx.reply(Strings.ai.askNoMessage, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) return } - const error = Strings.unexpectedErr.replace("{error}", aiResponse.error) - await rateLimiter.editMessageWithRetry( - ctx, - ctx.chat.id, - replyGenerating.message_id, - error, - { parse_mode: 'Markdown' } - ) + + const replyGenerating = await ctx.reply(Strings.ai.askGenerating.replace("{model}", customAiModel), { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }) + + logger.logPrompt(fixedMsg) + + const prompt = sanitizeForJson(await usingSystemPrompt(textCtx, db, botName)) + await handleAiReply(textCtx, db, customAiModel, prompt, replyGenerating, aiTemperature) }) } \ No newline at end of file diff --git a/src/commands/animal.ts b/src/commands/animal.ts index 63cbb7b..09c7ba2 100644 --- a/src/commands/animal.ts +++ b/src/commands/animal.ts @@ -9,124 +9,131 @@ import { languageCode } from '../utils/language-code'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -export default (bot: Telegraf) => { - bot.command("duck", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const reply_to_message_id = replyToMessageId(ctx); - try { - const response = await axios(Resources.duckApi); - ctx.replyWithPhoto(response.data.url, { - caption: "🦆", - ...({ reply_to_message_id }) - }); - } catch (error) { - const Strings = getStrings(languageCode(ctx)); - const message = Strings.duckApiErr.replace('{error}', error.message); - ctx.reply(message, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - return; - } - }); - - bot.command("fox", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { +export const duckHandler = async (ctx: Context & { message: { text: string } }) => { + const reply_to_message_id = replyToMessageId(ctx); + try { + const response = await axios(Resources.duckApi); + ctx.replyWithPhoto(response.data.url, { + caption: "🦆", + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } catch (error) { const Strings = getStrings(languageCode(ctx)); - const reply_to_message_id = replyToMessageId(ctx); - try { - const response = await axios(Resources.foxApi); - ctx.replyWithPhoto(response.data.image, { - caption: "🦊", - ...({ reply_to_message_id }) - }); - } catch (error) { - const message = Strings.foxApiErr.replace('{error}', error.message); - ctx.reply(message, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - return; - } - }); + const message = Strings.duckApiErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + return; + } +}; - bot.command("dog", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(languageCode(ctx)); - const reply_to_message_id = replyToMessageId(ctx); - try { - const response = await axios(Resources.dogApi); - ctx.replyWithPhoto(response.data.message, { - caption: "🐶", - ...({ reply_to_message_id }) - }); - } catch (error) { - const message = Strings.foxApiErr.replace('{error}', error.message); - ctx.reply(message, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - return; - } - }); +export const foxHandler = async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); + try { + const response = await axios(Resources.foxApi); + ctx.replyWithPhoto(response.data.image, { + caption: "🦊", + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } catch (error) { + const message = Strings.foxApiErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + return; + } +}; - bot.command("cat", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(languageCode(ctx)); - const apiUrl = `${Resources.catApi}?json=true`; +export const dogHandler = async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); + try { + const response = await axios(Resources.dogApi); + ctx.replyWithPhoto(response.data.message, { + caption: "🐶", + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } catch (error) { + const message = Strings.dogApiErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + return; + } +}; + +export const catHandler = async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(languageCode(ctx)); + const apiUrl = `${Resources.catApi}?json=true`; + const reply_to_message_id = replyToMessageId(ctx); + try { const response = await axios.get(apiUrl); const data = response.data; const imageUrl = `${data.url}`; - const reply_to_message_id = replyToMessageId(ctx); + await ctx.replyWithPhoto(imageUrl, { + caption: `🐱`, + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } catch (error) { + const message = Strings.catImgErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + return; + } +}; - try { - await ctx.replyWithPhoto(imageUrl, { - caption: `🐱`, +export const soggyHandler = async (ctx: Context & { message: { text: string } }) => { + const userInput = ctx.message.text.split(' ')[1]; + const reply_to_message_id = replyToMessageId(ctx); + + switch (true) { + case (userInput === "2" || userInput === "thumb"): + ctx.replyWithPhoto( + Resources.soggyCat2, { + caption: Resources.soggyCat2, parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); - } catch (error) { - ctx.reply(Strings.catImgErr, { + break; + + case (userInput === "3" || userInput === "sticker"): + ctx.replyWithSticker( + Resources.soggyCatSticker, + reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : undefined + ); + break; + + case (userInput === "4" || userInput === "alt"): + ctx.replyWithPhoto( + Resources.soggyCatAlt, { + caption: Resources.soggyCatAlt, parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); - }; - }); + break; - bot.command(['soggy', 'soggycat'], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const userInput = ctx.message.text.split(' ')[1]; - const reply_to_message_id = replyToMessageId(ctx); + default: + ctx.replyWithPhoto( + Resources.soggyCat, { + caption: Resources.soggyCat, + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + break; + }; +}; - switch (true) { - case (userInput === "2" || userInput === "thumb"): - ctx.replyWithPhoto( - Resources.soggyCat2, { - caption: Resources.soggyCat2, - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - break; - - case (userInput === "3" || userInput === "sticker"): - ctx.replyWithSticker( - Resources.soggyCatSticker, - reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : undefined - ); - break; - - case (userInput === "4" || userInput === "alt"): - ctx.replyWithPhoto( - Resources.soggyCatAlt, { - caption: Resources.soggyCatAlt, - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - break; - - default: - ctx.replyWithPhoto( - Resources.soggyCat, { - caption: Resources.soggyCat, - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - break; - }; - }); +export default (bot: Telegraf) => { + bot.command("duck", spamwatchMiddleware, duckHandler); + bot.command("fox", spamwatchMiddleware, foxHandler); + bot.command("dog", spamwatchMiddleware, dogHandler); + bot.command("cat", spamwatchMiddleware, catHandler); + bot.command(['soggy', 'soggycat'], spamwatchMiddleware, soggyHandler); } \ No newline at end of file diff --git a/src/commands/codename.ts b/src/commands/codename.ts index 11e3e65..a7d668c 100644 --- a/src/commands/codename.ts +++ b/src/commands/codename.ts @@ -5,8 +5,9 @@ import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; import verifyInput from '../plugins/verifyInput'; import { Context, Telegraf } from 'telegraf'; -import { languageCode } from '../utils/language-code'; import { replyToMessageId } from '../utils/reply-to-message-id'; +import * as schema from '../db/schema'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); @@ -29,10 +30,31 @@ export async function getDeviceByCodename(codename: string): Promise) => { +async function getUserAndStrings(ctx: Context, db?: NodePgDatabase): Promise<{ Strings: any, languageCode: string }> { + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { Strings, languageCode }; + } + const from = ctx.from; + if (db && from.id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(from.id)), limit: 1 }); + if (dbUser.length > 0) { + languageCode = dbUser[0].languageCode; + } + } + if (from.language_code && languageCode === 'en') { + languageCode = from.language_code; + console.warn('[WARN !] Falling back to Telegram language_code for user', from.id); + } + const Strings = getStrings(languageCode); + return { Strings, languageCode }; +} + +export default (bot: Telegraf, db) => { bot.command(['codename', 'whatis'], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { const userInput = ctx.message.text.split(" ").slice(1).join(" "); - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); const { noCodename } = Strings.codenameCheck; const reply_to_message_id = replyToMessageId(ctx); diff --git a/src/commands/crew.ts b/src/commands/crew.ts index 0614441..762bb93 100644 --- a/src/commands/crew.ts +++ b/src/commands/crew.ts @@ -5,10 +5,32 @@ import os from 'os'; import { exec } from 'child_process'; import { error } from 'console'; import { Context, Telegraf } from 'telegraf'; -import { languageCode } from '../utils/language-code'; +import * as schema from '../db/schema'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); +async function getUserAndStrings(ctx: Context, db?: NodePgDatabase): Promise<{ Strings: any, languageCode: string }> { + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { Strings, languageCode }; + } + const from = ctx.from; + if (db && from.id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(from.id)), limit: 1 }); + if (dbUser.length > 0) { + languageCode = dbUser[0].languageCode; + } + } + if (from.language_code && languageCode === 'en') { + languageCode = from.language_code; + console.warn('[WARN !] Falling back to Telegram language_code for user', from.id); + } + const Strings = getStrings(languageCode); + return { Strings, languageCode }; +} + function getGitCommitHash() { return new Promise((resolve, reject) => { exec('git rev-parse --short HEAD', (error, stdout, stderr) => { @@ -55,7 +77,7 @@ function getSystemInfo() { } async function handleAdminCommand(ctx: Context & { message: { text: string } }, action: () => Promise, successMessage: string, errorMessage: string) { - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx); const userId = ctx.from?.id; const adminArray = process.env.botAdmins ? process.env.botAdmins.split(',').map(id => parseInt(id.trim())) : []; if (userId && adminArray.includes(userId)) { @@ -64,80 +86,72 @@ async function handleAdminCommand(ctx: Context & { message: { text: string } }, if (successMessage) { ctx.reply(successMessage, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } } catch (error) { ctx.reply(errorMessage.replace(/{error}/g, error.message), { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } } else { ctx.reply(Strings.noPermission, { - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } } -export default (bot: Telegraf) => { +export default (bot: Telegraf, db) => { bot.command('getbotstats', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); handleAdminCommand(ctx, async () => { const stats = getSystemInfo(); await ctx.reply(stats, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }, '', Strings.errorRetrievingStats); }); bot.command('getbotcommit', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); handleAdminCommand(ctx, async () => { try { const commitHash = await getGitCommitHash(); await ctx.reply(Strings.gitCurrentCommit.replace(/{commitHash}/g, commitHash), { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } catch (error) { ctx.reply(Strings.gitErrRetrievingCommit.replace(/{error}/g, error), { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } }, '', Strings.gitErrRetrievingCommit); }); bot.command('updatebot', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); handleAdminCommand(ctx, async () => { try { const result = await updateBot(); await ctx.reply(Strings.botUpdated.replace(/{result}/g, result), { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } catch (error) { ctx.reply(Strings.errorUpdatingBot.replace(/{error}/g, error), { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } }, '', Strings.errorUpdatingBot); }); bot.command('setbotname', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); const botName = ctx.message.text.split(' ').slice(1).join(' '); handleAdminCommand(ctx, async () => { await ctx.telegram.setMyName(botName); @@ -145,7 +159,7 @@ export default (bot: Telegraf) => { }); bot.command('setbotdesc', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); const botDesc = ctx.message.text.split(' ').slice(1).join(' '); handleAdminCommand(ctx, async () => { await ctx.telegram.setMyDescription(botDesc); @@ -153,34 +167,31 @@ export default (bot: Telegraf) => { }); bot.command('botkickme', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); handleAdminCommand(ctx, async () => { if (!ctx.chat) { ctx.reply(Strings.chatNotFound, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); return; } ctx.reply(Strings.kickingMyself, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); await ctx.telegram.leaveChat(ctx.chat.id); }, '', Strings.kickingMyselfErr); }); bot.command('getfile', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); const botFile = ctx.message.text.split(' ').slice(1).join(' '); if (!botFile) { ctx.reply(Strings.noFileProvided, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); return; } @@ -192,14 +203,12 @@ export default (bot: Telegraf) => { source: botFile, caption: botFile }, { - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } catch (error) { ctx.reply(Strings.unexpectedErr.replace(/{error}/g, error.message), { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } }, '', Strings.unexpectedErr); @@ -217,21 +226,18 @@ export default (bot: Telegraf) => { if (error) { return ctx.reply(`\`${error.message}\``, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } if (stderr) { return ctx.reply(`\`${stderr}\``, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } ctx.reply(`\`${stdout}\``, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }); }, '', "Nope!"); @@ -247,14 +253,12 @@ export default (bot: Telegraf) => { const result = eval(code); ctx.reply(`Result: ${result}`, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } catch (error) { ctx.reply(`Error: ${error.message}`, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } }); diff --git a/src/commands/fun.ts b/src/commands/fun.ts index c394241..e045dea 100644 --- a/src/commands/fun.ts +++ b/src/commands/fun.ts @@ -3,46 +3,63 @@ import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import { Context, Telegraf } from 'telegraf'; -import { languageCode } from '../utils/language-code'; +import * as schema from '../db/schema'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -function sendRandomReply(ctx: Context & { message: { text: string } }, gifUrl: string, textKey: string) { - const Strings = getStrings(languageCode(ctx)); - const randomNumber = Math.floor(Math.random() * 100); - const shouldSendGif = randomNumber > 50; - - const caption = Strings[textKey].replace('{randomNum}', randomNumber) - - if (shouldSendGif) { - ctx.replyWithAnimation(gifUrl, { - caption, - parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id - }).catch(err => { - const gifErr = Strings.gifErr.replace('{err}', err); - ctx.reply(gifErr, { - parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id - }); - }); - } else { - ctx.reply(caption, { - parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id - }); +async function getUserAndStrings(ctx: Context, db?: NodePgDatabase): Promise<{ Strings: any, languageCode: string }> { + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { Strings, languageCode }; } + const from = ctx.from; + if (db && from.id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(from.id)), limit: 1 }); + if (dbUser.length > 0) { + languageCode = dbUser[0].languageCode; + } + } + if (from.language_code && languageCode === 'en') { + languageCode = from.language_code; + console.warn('[WARN !] Falling back to Telegram language_code for user', from.id); + } + const Strings = getStrings(languageCode); + return { Strings, languageCode }; } +function sendRandomReply(ctx: Context & { message: { text: string } }, gifUrl: string, textKey: string, db: any) { + getUserAndStrings(ctx, db).then(({ Strings }) => { + const randomNumber = Math.floor(Math.random() * 100); + const shouldSendGif = randomNumber > 50; + const caption = Strings[textKey].replace('{randomNum}', randomNumber); + if (shouldSendGif) { + ctx.replyWithAnimation(gifUrl, { + caption, + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }).catch(err => { + const gifErr = Strings.gifErr.replace('{err}', err); + ctx.reply(gifErr, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + }); + } else { + ctx.reply(caption, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } + }); +} -async function handleDiceCommand(ctx: Context & { message: { text: string } }, emoji: string, delay: number) { - const Strings = getStrings(languageCode(ctx)); +async function handleDiceCommand(ctx: Context & { message: { text: string } }, emoji: string, delay: number, db: any) { + const { Strings } = await getUserAndStrings(ctx, db); // @ts-ignore - const result = await ctx.sendDice({ emoji, reply_to_message_id: ctx.message.message_id }); + const result = await ctx.sendDice({ emoji, ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); const botResponse = Strings.funEmojiResult .replace('{emoji}', result.dice.emoji) .replace('{value}', result.dice.value); @@ -50,8 +67,7 @@ async function handleDiceCommand(ctx: Context & { message: { text: string } }, e setTimeout(() => { ctx.reply(botResponse, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }, delay); } @@ -60,54 +76,53 @@ function getRandomInt(max: number) { return Math.floor(Math.random() * (max + 1)); } -export default (bot: Telegraf) => { +export default (bot: Telegraf, db) => { bot.command('random', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); const randomValue = getRandomInt(10); const randomVStr = Strings.randomNum.replace('{number}', randomValue); ctx.reply( randomVStr, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }); // TODO: maybe send custom stickers to match result of the roll? i think there are pre-existing ones bot.command('dice', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - await handleDiceCommand(ctx, '🎲', 4000); + await handleDiceCommand(ctx, '🎲', 4000, db); }); bot.command('slot', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - await handleDiceCommand(ctx, '🎰', 3000); + await handleDiceCommand(ctx, '��', 3000, db); }); bot.command('ball', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - await handleDiceCommand(ctx, '⚽', 3000); + await handleDiceCommand(ctx, '⚽', 3000, db); }); bot.command('dart', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - await handleDiceCommand(ctx, '🎯', 3000); + await handleDiceCommand(ctx, '🎯', 3000, db); }); bot.command('bowling', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - await handleDiceCommand(ctx, '🎳', 3000); + await handleDiceCommand(ctx, '🎳', 3000, db); }); bot.command('idice', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const { Strings } = await getUserAndStrings(ctx, db); ctx.replyWithSticker( Resources.infiniteDice, { - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }); bot.command('furry', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - sendRandomReply(ctx, Resources.furryGif, 'furryAmount'); + sendRandomReply(ctx, Resources.furryGif, 'furryAmount', db); }); bot.command('gay', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - sendRandomReply(ctx, Resources.gayFlag, 'gayAmount'); + sendRandomReply(ctx, Resources.gayFlag, 'gayAmount', db); }); }; \ No newline at end of file diff --git a/src/commands/gsmarena.ts b/src/commands/gsmarena.ts index 1636c01..b4014a1 100644 --- a/src/commands/gsmarena.ts +++ b/src/commands/gsmarena.ts @@ -9,6 +9,8 @@ import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; import { parse } from 'node-html-parser'; import { getDeviceByCodename } from './codename'; +import { getStrings } from '../plugins/checklang'; +import { languageCode } from '../utils/language-code'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); @@ -207,68 +209,130 @@ function getUsername(ctx){ return userName; } +const deviceSelectionCache: Record = {}; +const lastSelectionMessageId: Record = {}; + export default (bot) => { bot.command(['d', 'device'], spamwatchMiddleware, async (ctx) => { const userId = ctx.from.id; const userName = getUsername(ctx); + const Strings = getStrings(languageCode(ctx)); const phone = ctx.message.text.split(" ").slice(1).join(" "); if (!phone) { - return ctx.reply("Please provide the phone name.", { reply_to_message_id: ctx.message.message_id }); + return ctx.reply(Strings.gsmarenaProvidePhoneName || "[TODO: Add gsmarenaProvidePhoneName to locales] Please provide the phone name.", { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } console.log("[GSMArena] Searching for", phone); - const statusMsg = await ctx.reply(`Searching for \`${phone}\`...`, { reply_to_message_id: ctx.message.message_id, parse_mode: 'Markdown' }); + const statusMsg = await ctx.reply((Strings.gsmarenaSearchingFor || "[TODO: Add gsmarenaSearchingFor to locales] Searching for {phone}...").replace('{phone}', phone), { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}), parse_mode: 'Markdown' }); let results = await searchPhone(phone); if (results.length === 0) { const codenameResults = await getDeviceByCodename(phone.split(" ")[0]); if (!codenameResults) { - await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, `No phones found for \`${phone}\`.`, { parse_mode: 'Markdown' }); + await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, (Strings.gsmarenaNoPhonesFound || "[TODO: Add gsmarenaNoPhonesFound to locales] No phones found for {phone}.").replace('{phone}', phone), { parse_mode: 'Markdown' }); return; } - await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, `Searching for ${codenameResults.name}...`, { parse_mode: 'Markdown' }); + await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, (Strings.gsmarenaSearchingFor || "[TODO: Add gsmarenaSearchingFor to locales] Searching for {phone}...").replace('{phone}', codenameResults.name), { parse_mode: 'Markdown' }); const nameResults = await searchPhone(codenameResults.name); if (nameResults.length === 0) { - await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, `No phones found for \`${codenameResults.name}\` and \`${phone}\`.`, { parse_mode: 'Markdown' }); + await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, (Strings.gsmarenaNoPhonesFoundBoth || "[TODO: Add gsmarenaNoPhonesFoundBoth to locales] No phones found for {name} and {phone}.").replace('{name}', codenameResults.name).replace('{phone}', phone), { parse_mode: 'Markdown' }); return; } results = nameResults; } - const testUser = `${userName}, please select your device:`; - const options = { - parse_mode: 'HTML', - reply_to_message_id: ctx.message.message_id, - disable_web_page_preview: true, - reply_markup: { - inline_keyboard: results.map(result => [{ text: result.name, callback_data: `details:${result.url}:${ctx.from.id}` }]) - } + if (deviceSelectionCache[userId]?.timeout) { + clearTimeout(deviceSelectionCache[userId].timeout); + } + deviceSelectionCache[userId] = { + results, + timeout: setTimeout(() => { delete deviceSelectionCache[userId]; }, 5 * 60 * 1000) }; - await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, testUser, options); + + if (lastSelectionMessageId[userId]) { + try { + await ctx.telegram.editMessageText( + ctx.chat.id, + lastSelectionMessageId[userId], + undefined, + Strings.gsmarenaSelectDevice || "[TODO: Add gsmarenaSelectDevice to locales] Please select your device:", + { + parse_mode: 'HTML', + reply_to_message_id: ctx.message.message_id, + disable_web_page_preview: true, + reply_markup: { + inline_keyboard: results.map((result, idx) => { + const callbackData = `gsmadetails:${idx}:${ctx.from.id}`; + return [{ text: result.name, callback_data: callbackData }]; + }) + } + } + ); + } catch (e) { + const testUser = `${userName}, ${Strings.gsmarenaSelectDevice || "[TODO: Add gsmarenaSelectDevice to locales] please select your device:"}`; + const options = { + parse_mode: 'HTML', + reply_to_message_id: ctx.message.message_id, + disable_web_page_preview: true, + reply_markup: { + inline_keyboard: results.map((result, idx) => { + const callbackData = `gsmadetails:${idx}:${ctx.from.id}`; + return [{ text: result.name, callback_data: callbackData }]; + }) + } + }; + const selectionMsg = await ctx.reply(testUser, options); + lastSelectionMessageId[userId] = selectionMsg.message_id; + } + } else { + const testUser = `${userName}, ${Strings.gsmarenaSelectDevice || "[TODO: Add gsmarenaSelectDevice to locales] please select your device:"}`; + const inlineKeyboard = results.map((result, idx) => { + const callbackData = `gsmadetails:${idx}:${ctx.from.id}`; + return [{ text: result.name, callback_data: callbackData }]; + }); + const options = { + parse_mode: 'HTML', + reply_to_message_id: ctx.message.message_id, + disable_web_page_preview: true, + reply_markup: { + inline_keyboard: inlineKeyboard + } + }; + const selectionMsg = await ctx.reply(testUser, options); + lastSelectionMessageId[userId] = selectionMsg.message_id; + } + await ctx.telegram.deleteMessage(ctx.chat.id, statusMsg.message_id).catch(() => {}); }); - bot.action(/details:(.+):(.+)/, async (ctx) => { - const url = ctx.match[1]; + bot.action(/gsmadetails:(\d+):(\d+)/, async (ctx) => { + const idx = parseInt(ctx.match[1]); const userId = parseInt(ctx.match[2]); const userName = getUsername(ctx); + const Strings = getStrings(languageCode(ctx)); const callbackQueryUserId = ctx.update.callback_query.from.id; if (userId !== callbackQueryUserId) { - return ctx.answerCbQuery(`${userName}, you are not allowed to interact with this.`); + return ctx.answerCbQuery(`${userName}, ${Strings.gsmarenaNotAllowed || "[TODO: Add gsmarenaNotAllowed to locales] you are not allowed to interact with this."}`); } ctx.answerCbQuery(); + const cache = deviceSelectionCache[userId]; + if (!cache || !cache.results[idx]) { + return ctx.reply(Strings.gsmarenaInvalidOrExpired || "[TODO: Add gsmarenaInvalidOrExpired to locales] Whoops, invalid or expired option. Please try again.", { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); + } + const url = cache.results[idx].url; + const phoneDetails = await checkPhoneDetails(url); if (phoneDetails.name) { const message = formatPhone(phoneDetails); - ctx.editMessageText(`${userName}, these are the details of your device:` + message, { parse_mode: 'HTML', disable_web_page_preview: false }); + ctx.editMessageText(`${userName}, ${Strings.gsmarenaDeviceDetails || "[TODO: Add gsmarenaDeviceDetails to locales] these are the details of your device:"}` + message, { parse_mode: 'HTML', disable_web_page_preview: false }); } else { - ctx.reply("Error fetching phone details.", { reply_to_message_id: ctx.message.message_id }); + ctx.reply(Strings.gsmarenaErrorFetchingDetails || "[TODO: Add gsmarenaErrorFetchingDetails to locales] Error fetching phone details.", { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } }); }; diff --git a/src/commands/help.ts b/src/commands/help.ts index 3a6d3a0..f01f5e5 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,21 +1,38 @@ import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; -import { languageCode } from '../utils/language-code'; +import type { Context } from 'telegraf'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); +async function getUserAndStrings(ctx: Context, db?: any): Promise<{ Strings: any, languageCode: string }> { + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { Strings, languageCode }; + } + const from = ctx.from; + if (db && from.id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(from.id)), limit: 1 }); + if (dbUser.length > 0) { + languageCode = dbUser[0].languageCode; + } + } + const Strings = getStrings(languageCode); + return { Strings, languageCode }; +} + interface MessageOptions { parse_mode: string; disable_web_page_preview: boolean; reply_markup: { - inline_keyboard: { text: any; callback_data: string; }[][]; + inline_keyboard: { text: string; callback_data: string; }[][]; }; reply_to_message_id?: number; } -async function sendHelpMessage(ctx, isEditing) { - const Strings = getStrings(languageCode(ctx)); +async function sendHelpMessage(ctx, isEditing, db) { + const { Strings } = await getUserAndStrings(ctx, db); const botInfo = await ctx.telegram.getMe(); const helpText = Strings.botHelp .replace(/{botName}/g, botInfo.first_name) @@ -33,14 +50,14 @@ async function sendHelpMessage(ctx, isEditing) { [{ 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.aiCmds, callback_data: 'helpAi' }] + [{ text: Strings.ai.helpEntry, callback_data: 'helpAi' }] ] } }; if (includeReplyTo) { const messageId = getMessageId(ctx); if (messageId) { - options.reply_to_message_id = messageId; + (options as any).reply_parameters = { message_id: messageId }; }; }; return options; @@ -52,78 +69,78 @@ async function sendHelpMessage(ctx, isEditing) { }; } -export default (bot) => { +export default (bot, db) => { bot.help(spamwatchMiddleware, async (ctx) => { - await sendHelpMessage(ctx, false); + await sendHelpMessage(ctx, false, db); }); bot.command("about", spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); const aboutMsg = Strings.botAbout.replace(/{sourceLink}/g, `${process.env.botSource}`); ctx.reply(aboutMsg, { parse_mode: 'Markdown', disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); - }) + }); - bot.on('callback_query', async (ctx) => { - const callbackData = ctx.callbackQuery.data; - const Strings = getStrings(languageCode(ctx)); - const options = { - parse_mode: 'Markdown', - disable_web_page_preview: true, - reply_markup: JSON.stringify({ - inline_keyboard: [ - [{ text: Strings.varStrings.varBack, callback_data: 'helpBack' }], - ] - }) - }; + const options = (Strings) => ({ + parse_mode: 'Markdown', + disable_web_page_preview: true, + reply_markup: JSON.stringify({ + inline_keyboard: [ + [{ text: Strings.varStrings.varBack, callback_data: 'helpBack' }], + ] + }) + }); - switch (callbackData) { - case 'helpMain': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.mainCommandsDesc, options); - break; - case 'helpUseful': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.usefulCommandsDesc, options); - break; - case 'helpInteractive': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.interactiveEmojisDesc, options); - break; - case 'helpFunny': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.funnyCommandsDesc, options); - break; - case 'helpLast': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.lastFm.helpDesc, options); - break; - case 'helpYouTube': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.ytDownload.helpDesc, options); - break; - case 'helpAnimals': - await ctx.answerCbQuery(); - await ctx.editMessageText(Strings.animalCommandsDesc, options); - break; - case 'helpMLP': - 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); - break; - default: - await ctx.answerCbQuery(Strings.errInvalidOption); - break; - } + bot.action('helpMain', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.mainCommandsDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpUseful', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.usefulCommandsDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpInteractive', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.interactiveEmojisDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpFunny', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.funnyCommandsDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpLast', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.lastFm.helpDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpYouTube', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.ytDownload.helpDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpAnimals', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.animalCommandsDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpMLP', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.ponyApi.helpDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpAi', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.ai.helpDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpBack', async (ctx) => { + await sendHelpMessage(ctx, true, db); + await ctx.answerCbQuery(); }); } diff --git a/src/commands/http.ts b/src/commands/http.ts index b1fe636..9ef0fdb 100644 --- a/src/commands/http.ts +++ b/src/commands/http.ts @@ -5,14 +5,37 @@ import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import axios from 'axios'; import verifyInput from '../plugins/verifyInput'; import { Context, Telegraf } from 'telegraf'; +import * as schema from '../db/schema'; import { languageCode } from '../utils/language-code'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -export default (bot: Telegraf) => { +async function getUserAndStrings(ctx: Context, db?: NodePgDatabase): Promise<{ Strings: any, languageCode: string }> { + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { Strings, languageCode }; + } + const from = ctx.from; + if (db && from.id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(from.id)), limit: 1 }); + if (dbUser.length > 0) { + languageCode = dbUser[0].languageCode; + } + } + if (from.language_code && languageCode === 'en') { + languageCode = from.language_code; + console.warn('[WARN !] Falling back to Telegram language_code for user', from.id); + } + const Strings = getStrings(languageCode); + return { Strings, languageCode }; +} + +export default (bot: Telegraf, db) => { bot.command("http", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { const reply_to_message_id = ctx.message.message_id; - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); const userInput = ctx.message.text.split(' ')[1]; const apiUrl = Resources.httpApi; const { invalidCode } = Strings.httpCodes @@ -34,19 +57,19 @@ export default (bot: Telegraf) => { .replace("{description}", codeInfo.description); await ctx.reply(message, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } else { await ctx.reply(Strings.httpCodes.notFound, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; } catch (error) { - const message = Strings.httpCodes.fetchErr.replace("{error}", error); + const message = Strings.httpCodes.fetchErr.replace('{error}', error); ctx.reply(message, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; }); @@ -63,7 +86,7 @@ export default (bot: Telegraf) => { if (userInput.length !== 3) { ctx.reply(Strings.httpCodes.invalidCode, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }) return } @@ -74,12 +97,12 @@ export default (bot: Telegraf) => { await ctx.replyWithPhoto(apiUrl, { caption: `🐱 ${apiUrl}`, parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } catch (error) { ctx.reply(Strings.catImgErr, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } }); diff --git a/src/commands/info.ts b/src/commands/info.ts index 2bf2b2d..c9f8042 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -2,64 +2,81 @@ import { getStrings } from '../plugins/checklang'; import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import { Context, Telegraf } from 'telegraf'; +import * as schema from '../db/schema'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -async function getUserInfo(ctx: Context & { message: { text: string } }) { - const Strings = getStrings(ctx.from?.language_code || 'en'); +async function getUserAndStrings(ctx: Context, db?: NodePgDatabase): Promise<{ Strings: any, languageCode: string }> { + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { Strings, languageCode }; + } + const from = ctx.from; + if (db && from.id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(from.id)), limit: 1 }); + if (dbUser.length > 0) { + languageCode = dbUser[0].languageCode; + } + } + if (from.language_code && languageCode === 'en') { + languageCode = from.language_code; + console.warn('[WARN !] Falling back to Telegram language_code for user', from.id); + } + const Strings = getStrings(languageCode); + return { Strings, languageCode }; +} + +async function getUserInfo(ctx: Context & { message: { text: string } }, db: any) { + const { Strings } = await getUserAndStrings(ctx, db); let lastName = ctx.from?.last_name; if (lastName === undefined) { lastName = " "; } - const userInfo = Strings.userInfo .replace('{userName}', `${ctx.from?.first_name} ${lastName}` || Strings.varStrings.varUnknown) .replace('{userId}', ctx.from?.id || Strings.varStrings.varUnknown) .replace('{userHandle}', ctx.from?.username ? `@${ctx.from?.username}` : Strings.varStrings.varNone) .replace('{userPremium}', ctx.from?.is_premium ? Strings.varStrings.varYes : Strings.varStrings.varNo) .replace('{userLang}', ctx.from?.language_code || Strings.varStrings.varUnknown); - return userInfo; } -async function getChatInfo(ctx: Context & { message: { text: string } }) { - const Strings = getStrings(ctx.from?.language_code || 'en'); - if (ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup') { +async function getChatInfo(ctx: Context & { message: { text: string } }, db: any) { + const { Strings } = await getUserAndStrings(ctx, db); + if ((ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup')) { + const chat = ctx.chat as (typeof ctx.chat & { username?: string; is_forum?: boolean }); const chatInfo = Strings.chatInfo - .replace('{chatId}', ctx.chat?.id || Strings.varStrings.varUnknown) - .replace('{chatName}', ctx.chat?.title || Strings.varStrings.varUnknown) - // @ts-ignore - .replace('{chatHandle}', ctx.chat?.username ? `@${ctx.chat?.username}` : Strings.varStrings.varNone) + .replace('{chatId}', chat?.id || Strings.varStrings.varUnknown) + .replace('{chatName}', chat?.title || Strings.varStrings.varUnknown) + .replace('{chatHandle}', chat?.username ? `@${chat.username}` : Strings.varStrings.varNone) .replace('{chatMembersCount}', await ctx.getChatMembersCount()) - .replace('{chatType}', ctx.chat?.type || Strings.varStrings.varUnknown) - // @ts-ignore - .replace('{isForum}', ctx.chat?.is_forum ? Strings.varStrings.varYes : Strings.varStrings.varNo); - + .replace('{chatType}', chat?.type || Strings.varStrings.varUnknown) + .replace('{isForum}', chat?.is_forum ? Strings.varStrings.varYes : Strings.varStrings.varNo); return chatInfo; } else { - return Strings.groupOnly + return Strings.groupOnly; } } -export default (bot: Telegraf) => { +export default (bot: Telegraf, db) => { bot.command('chatinfo', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const chatInfo = await getChatInfo(ctx); + const chatInfo = await getChatInfo(ctx, db); ctx.reply( chatInfo, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) } ); }); bot.command('userinfo', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const userInfo = await getUserInfo(ctx); + const userInfo = await getUserInfo(ctx, db); ctx.reply( userInfo, { parse_mode: 'Markdown', - // @ts-ignore - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) } ); }); diff --git a/src/commands/lastfm.ts b/src/commands/lastfm.ts index 39d6c8d..d51ca25 100644 --- a/src/commands/lastfm.ts +++ b/src/commands/lastfm.ts @@ -72,7 +72,7 @@ export default (bot) => { return ctx.reply(Strings.lastFm.noUser, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; @@ -84,7 +84,7 @@ export default (bot) => { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }); @@ -94,12 +94,12 @@ export default (bot) => { const lastfmUser = users[userId]; const genericImg = Resources.lastFmGenericImg; const botInfo = await ctx.telegram.getMe(); - + if (!lastfmUser) { return ctx.reply(Strings.lastFm.noUserSet, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; @@ -124,7 +124,7 @@ export default (bot) => { return ctx.reply(noRecent, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; @@ -137,8 +137,8 @@ export default (bot) => { if (albumMbid) { imageUrl = await getFromMusicBrainz(albumMbid); - } - + } + if (!imageUrl) { imageUrl = getFromLast(track); } @@ -166,7 +166,7 @@ export default (bot) => { 'User-Agent': `@${botInfo.username}-node-telegram-bot` } }); - + num_plays = response_plays.data.track.userplaycount; } catch (err) { console.log(err) @@ -176,7 +176,7 @@ export default (bot) => { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; @@ -200,13 +200,13 @@ export default (bot) => { caption: message, parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } else { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; } catch (err) { @@ -217,7 +217,7 @@ export default (bot) => { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; }); diff --git a/src/commands/main.ts b/src/commands/main.ts index a5d581c..a6c48ba 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -3,31 +3,390 @@ import { isOnSpamWatch } from '../spamwatch/spamwatch'; import spamwatchMiddlewareModule from '../spamwatch/Middleware'; import { Context, Telegraf } from 'telegraf'; import { replyToMessageId } from '../utils/reply-to-message-id'; -import { languageCode } from '../utils/language-code'; +import * as schema from '../db/schema'; +import { eq } from 'drizzle-orm'; +import { ensureUserInDb } from '../utils/ensure-user'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { models } from './ai'; +import { langs } from '../locales/config'; + +type UserRow = typeof schema.usersTable.$inferSelect; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -export default (bot: Telegraf) => { - bot.start(spamwatchMiddleware, async (ctx: Context) => { - const Strings = getStrings(languageCode(ctx)); - const botInfo = await ctx.telegram.getMe(); - const reply_to_message_id = replyToMessageId(ctx) - const startMsg = Strings.botWelcome.replace(/{botName}/g, botInfo.first_name); +async function getUserAndStrings(ctx: Context, db: NodePgDatabase): Promise<{ user: UserRow | null, Strings: any, languageCode: string }> { + let user: UserRow | null = null; + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { user, Strings, languageCode }; + } + const { id, language_code } = ctx.from; + if (id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(id)), limit: 1 }); + if (dbUser.length === 0) { + await ensureUserInDb(ctx, db); + const newUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(id)), limit: 1 }); + if (newUser.length > 0) { + user = newUser[0]; + languageCode = user.languageCode; + } + } else { + user = dbUser[0]; + languageCode = user.languageCode; + } + } + if (!user && language_code) { + languageCode = language_code; + console.warn('[WARN !] Falling back to Telegram language_code for user', id); + } + const Strings = getStrings(languageCode); + return { user, Strings, languageCode }; +} - ctx.reply(startMsg, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); +type SettingsMenu = { text: string, reply_markup: any }; +function getSettingsMenu(user: UserRow, Strings: any): SettingsMenu { + const langObj = langs.find(l => l.code === user.languageCode); + const langLabel = langObj ? langObj.label : user.languageCode; + return { + text: Strings.settings.selectSetting, + reply_markup: { + inline_keyboard: [ + [ + { text: `✨ ${Strings.settings.ai.aiEnabled}: ${user.aiEnabled ? Strings.settings.enabled : Strings.settings.disabled}`, callback_data: 'settings_aiEnabled' }, + { text: `🧠 ${Strings.settings.ai.aiModel}: ${user.customAiModel}`, callback_data: 'settings_aiModel' } + ], + [ + { text: `🌡️ ${Strings.settings.ai.aiTemperature}: ${user.aiTemperature}`, callback_data: 'settings_aiTemperature' }, + { text: `🌐 ${langLabel}`, callback_data: 'settings_language' } + ] + ] + } + }; +} + +export default (bot: Telegraf, db: NodePgDatabase) => { + bot.start(spamwatchMiddleware, async (ctx: Context) => { + const { user, Strings } = await getUserAndStrings(ctx, db); + const botInfo = await ctx.telegram.getMe(); + const reply_to_message_id = replyToMessageId(ctx); + const startMsg = Strings.botWelcome.replace(/{botName}/g, botInfo.first_name); + if (!user) return; + ctx.reply( + startMsg.replace( + /{aiEnabled}/g, + user.aiEnabled ? Strings.settings.enabled : Strings.settings.disabled + ).replace( + /{aiModel}/g, + user.customAiModel + ).replace( + /{aiTemperature}/g, + user.aiTemperature.toString() + ).replace( + /{aiRequests}/g, + user.aiRequests.toString() + ).replace( + /{aiCharacters}/g, + user.aiCharacters.toString() + ).replace( + /{languageCode}/g, + user.languageCode + ), { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + } + ); }); - bot.command('privacy', spamwatchMiddleware, async (ctx: any) => { - const Strings = getStrings(ctx.from.language_code); - const message = Strings.botPrivacy.replace("{botPrivacy}", process.env.botPrivacy); + bot.command(["settings"], spamwatchMiddleware, async (ctx: Context) => { + const reply_to_message_id = replyToMessageId(ctx); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const menu = getSettingsMenu(user, Strings); + await ctx.reply( + menu.text, + { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + } + ); + }); + const updateSettingsKeyboard = async (ctx: Context, user: UserRow, Strings: any) => { + const menu = getSettingsMenu(user, Strings); + await ctx.editMessageReplyMarkup(menu.reply_markup); + }; + + bot.action('settings_aiEnabled', async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + await db.update(schema.usersTable) + .set({ aiEnabled: !user.aiEnabled }) + .where(eq(schema.usersTable.telegramId, String(user.telegramId))); + const updatedUser = (await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(user.telegramId)), limit: 1 }))[0]; + await updateSettingsKeyboard(ctx, updatedUser, Strings); + } catch (err) { + console.error('Error handling settings_aiEnabled callback:', err); + } + }); + + bot.action('settings_aiModel', async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + try { + await ctx.editMessageText( + `${Strings.settings.ai.selectSeries}`, + { + reply_markup: { + inline_keyboard: models.map(series => [ + { text: series.label, callback_data: `selectseries_${series.name}` } + ]).concat([[ + { text: `⬅️ ${Strings.settings.ai.back}`, callback_data: 'settings_back' } + ]]) + } + } + ); + } catch (err) { + if ( + !( + err.response.description?.includes('query is too old') || + err.response.description?.includes('query ID is invalid') || + err.response.description?.includes('message is not modified') || + err.response.description?.includes('message to edit not found') + ) + ) + console.error('Unexpected Telegram error:', err); + } + } catch (err) { + console.error('Error handling settings_aiModel callback:', err); + } + }); + + bot.action(/^selectseries_.+$/, async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const data = (ctx.callbackQuery as any).data; + const seriesName = data.replace('selectseries_', ''); + const series = models.find(s => s.name === seriesName); + if (!series) return; + const desc = user.languageCode === 'pt' ? series.descriptionPt : series.descriptionEn; + try { + await ctx.editMessageText( + `${Strings.settings.ai.seriesDescription.replace('{seriesDescription}', desc)}\n\n${Strings.settings.ai.selectParameterSize.replace('{seriesLabel}', series.label)}\n\n${Strings.settings.ai.parameterSizeExplanation}`, + { + reply_markup: { + inline_keyboard: series.models.map(m => [ + { text: `${m.label} (${m.parameterSize})`, callback_data: `setmodel_${series.name}_${m.name}` } + ]).concat([[ + { text: `⬅️ ${Strings.settings.ai.back}`, callback_data: 'settings_aiModel' } + ]]) + } + } + ); + } catch (err) { + if ( + !( + err.response.description?.includes('query is too old') || + err.response.description?.includes('query ID is invalid') || + err.response.description?.includes('message is not modified') || + err.response.description?.includes('message to edit not found') + ) + ) + console.error('Unexpected Telegram error:', err); + } + } catch (err) { + console.error('Error handling selectseries callback:', err); + } + }); + + bot.action(/^setmodel_.+$/, async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const data = (ctx.callbackQuery as any).data; + const parts = data.split('_'); + const seriesName = parts[1]; + const modelName = parts.slice(2).join('_'); + const series = models.find(s => s.name === seriesName); + const model = series?.models.find(m => m.name === modelName); + if (!series || !model) return; + await db.update(schema.usersTable) + .set({ customAiModel: model.name }) + .where(eq(schema.usersTable.telegramId, String(user.telegramId))); + const updatedUser = (await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(user.telegramId)), limit: 1 }))[0]; + const menu = getSettingsMenu(updatedUser, Strings); + try { + if (ctx.callbackQuery.message) { + await ctx.editMessageText( + menu.text, + { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + } + ); + } else { + await ctx.reply(menu.text, { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + }); + } + } catch (err) { + if ( + !( + err.response.description?.includes('query is too old') || + err.response.description?.includes('query ID is invalid') || + err.response.description?.includes('message is not modified') || + err.response.description?.includes('message to edit not found') + ) + ) + console.error('[Settings] Unexpected Telegram error:', err); + } + } catch (err) { + console.error('Error handling setmodel callback:', err); + } + }); + + bot.action('settings_aiTemperature', async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const temps = [0.2, 0.5, 0.7, 0.9, 1.2]; + try { + await ctx.editMessageReplyMarkup({ + inline_keyboard: temps.map(t => [{ text: t.toString(), callback_data: `settemp_${t}` }]).concat([[{ text: `⬅️ ${Strings.settings.ai.back}`, callback_data: 'settings_back' }]]) + }); + } catch (err) { + if ( + !( + err.response.description?.includes('query is too old') || + err.response.description?.includes('query ID is invalid') || + err.response.description?.includes('message is not modified') || + err.response.description?.includes('message to edit not found') + ) + ) + console.error('Unexpected Telegram error:', err); + } + } catch (err) { + console.error('Error handling settings_aiTemperature callback:', err); + } + }); + + bot.action(/^settemp_.+$/, async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const data = (ctx.callbackQuery as any).data; + const temp = parseFloat(data.replace('settemp_', '')); + await db.update(schema.usersTable) + .set({ aiTemperature: temp }) + .where(eq(schema.usersTable.telegramId, String(user.telegramId))); + const updatedUser = (await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(user.telegramId)), limit: 1 }))[0]; + await updateSettingsKeyboard(ctx, updatedUser, Strings); + } catch (err) { + console.error('Error handling settemp callback:', err); + } + }); + + bot.action('settings_language', async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + try { + await ctx.editMessageReplyMarkup({ + inline_keyboard: langs.map(l => [{ text: l.label, callback_data: `setlang_${l.code}` }]).concat([[{ text: `⬅️ ${Strings.settings.ai.back}`, callback_data: 'settings_back' }]]) + }); + } catch (err) { + if ( + !( + err.response.description?.includes('query is too old') || + err.response.description?.includes('query ID is invalid') || + err.response.description?.includes('message is not modified') || + err.response.description?.includes('message to edit not found') + ) + ) + console.error('Unexpected Telegram error:', err); + } + } catch (err) { + console.error('Error handling settings_language callback:', err); + } + }); + + bot.action('settings_back', async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + await updateSettingsKeyboard(ctx, user, Strings); + } catch (err) { + console.error('Error handling settings_back callback:', err); + } + }); + + bot.command('privacy', spamwatchMiddleware, async (ctx: Context) => { + const { Strings } = await getUserAndStrings(ctx, db); + if (!ctx.from || !ctx.message) return; + const message = Strings.botPrivacy.replace("{botPrivacy}", process.env.botPrivacy ?? ""); ctx.reply(message, { parse_mode: 'Markdown', - disable_web_page_preview: true, reply_to_message_id: ctx.message.message_id - }); + } as any); + }); + + bot.action(/^setlang_.+$/, async (ctx) => { + try { + await ctx.answerCbQuery(); + const { user } = await getUserAndStrings(ctx, db); + if (!user) { + console.log('[Settings] No user found'); + return; + } + const data = (ctx.callbackQuery as any).data; + const lang = data.replace('setlang_', ''); + await db.update(schema.usersTable) + .set({ languageCode: lang }) + .where(eq(schema.usersTable.telegramId, String(user.telegramId))); + const updatedUser = (await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(user.telegramId)), limit: 1 }))[0]; + const updatedStrings = getStrings(updatedUser.languageCode); + const menu = getSettingsMenu(updatedUser, updatedStrings); + try { + if (ctx.callbackQuery.message) { + await ctx.editMessageText( + menu.text, + { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + } + ); + } else { + await ctx.reply(menu.text, { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + }); + } + } catch (err) { + if ( + !( + err.response.description?.includes('query is too old') || + err.response.description?.includes('query ID is invalid') || + err.response.description?.includes('message is not modified') || + err.response.description?.includes('message to edit not found') + ) + ) + console.error('[Settings] Unexpected Telegram error:', err); + } + } catch (err) { + console.error('[Settings] Error handling setlang callback:', err); + } }); }; \ No newline at end of file diff --git a/src/commands/modarchive.ts b/src/commands/modarchive.ts index 7d1489e..5f7333b 100644 --- a/src/commands/modarchive.ts +++ b/src/commands/modarchive.ts @@ -24,22 +24,17 @@ async function downloadModule(moduleId: string): Promise { method: 'GET', responseType: 'stream', }); - const disposition = response.headers['content-disposition']; let fileName = moduleId; - if (disposition && disposition.includes('filename=')) { fileName = disposition .split('filename=')[1] .split(';')[0] .replace(/['"]/g, ''); } - - const filePath = path.resolve(__dirname, fileName); - + const filePath = path.join(__dirname, fileName); const writer = fs.createWriteStream(filePath); response.data.pipe(writer); - return new Promise((resolve, reject) => { writer.on('finish', () => resolve({ filePath, fileName })); writer.on('error', reject); @@ -49,39 +44,41 @@ async function downloadModule(moduleId: string): Promise { } } -export default (bot: Telegraf) => { - bot.command(['modarchive', 'tma'], spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(languageCode(ctx)); - const reply_to_message_id = replyToMessageId(ctx); - const moduleId = ctx.message?.text.split(' ')[1]; - - if (Number.isNaN(moduleId) || null) { - return ctx.reply(Strings.maInvalidModule, { - parse_mode: "Markdown", - ...({ reply_to_message_id }) - }); - } - const numberRegex = /^\d+$/; - const isNumber = numberRegex.test(moduleId); - if (isNumber) { - const result = await downloadModule(moduleId); - if (result) { - const { filePath, fileName } = result; - const regexExtension = /\.\w+$/i; - const hasExtension = regexExtension.test(fileName); - if (hasExtension) { - await ctx.replyWithDocument({ source: filePath }, { - caption: fileName, - ...({ reply_to_message_id }) - }); - fs.unlinkSync(filePath); - return; - } - } - } +export const modarchiveHandler = async (ctx: Context) => { + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); + const moduleId = ctx.message && 'text' in ctx.message && typeof ctx.message.text === 'string' + ? ctx.message.text.split(' ')[1]?.trim() + : undefined; + if (!moduleId || !/^\d+$/.test(moduleId)) { return ctx.reply(Strings.maInvalidModule, { parse_mode: "Markdown", - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); + } + const result = await downloadModule(moduleId); + if (result) { + const { filePath, fileName } = result; + const regexExtension = /\.\w+$/i; + const hasExtension = regexExtension.test(fileName); + if (hasExtension) { + try { + await ctx.replyWithDocument({ source: filePath }, { + caption: fileName, + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } finally { + try { fs.unlinkSync(filePath); } catch (e) { /* ignore */ } + } + return; + } + } + return ctx.reply(Strings.maInvalidModule, { + parse_mode: "Markdown", + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; + +export default (bot: Telegraf) => { + bot.command(['modarchive', 'tma'], spamwatchMiddleware, modarchiveHandler); +}; diff --git a/src/commands/ponyapi.ts b/src/commands/ponyapi.ts index daf99c7..7f6320c 100644 --- a/src/commands/ponyapi.ts +++ b/src/commands/ponyapi.ts @@ -53,34 +53,38 @@ function capitalizeFirstLetter(letter: string) { return letter.charAt(0).toUpperCase() + letter.slice(1); } +function sendReply(ctx: Context, text: string, reply_to_message_id?: number) { + return ctx.reply(text, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); +} + +function sendPhoto(ctx: Context, photo: string, caption: string, reply_to_message_id?: number) { + return ctx.replyWithPhoto(photo, { + caption, + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); +} + export default (bot: Telegraf) => { bot.command("mlp", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { const Strings = getStrings(languageCode(ctx)); const reply_to_message_id = replyToMessageId(ctx); - - ctx.reply(Strings.ponyApi.helpDesc, { - parse_mode: 'Markdown', - ...({ reply_to_message_id, disable_web_page_preview: true }) - }); + sendReply(ctx, Strings.ponyApi.helpDesc, reply_to_message_id); }); bot.command("mlpchar", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const { message } = ctx; const reply_to_message_id = replyToMessageId(ctx); const Strings = getStrings(languageCode(ctx) || 'en'); - const userInput = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+"); - const { noCharName } = Strings.ponyApi + const userInput = message.text.split(' ').slice(1).join(' ').trim().replace(/\s+/g, '+'); + const { noCharName } = Strings.ponyApi; - if (verifyInput(ctx, userInput, noCharName)) { - return; - } - - // if special characters or numbers (max 30 characters) - if (/[^a-zA-Z\s]/.test(userInput) || userInput.length > 30) { - ctx.reply(Strings.mlpInvalidCharacter, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - return; + if (verifyInput(ctx, userInput, noCharName)) return; + if (!userInput || /[^a-zA-Z\s]/.test(userInput) || userInput.length > 30) { + return sendReply(ctx, Strings.mlpInvalidCharacter, reply_to_message_id); } const capitalizedInput = capitalizeFirstLetter(userInput); @@ -88,62 +92,29 @@ export default (bot: Telegraf) => { try { const response = await axios(apiUrl); - const charactersArray: Character[] = []; - - if (Array.isArray(response.data.data)) { - response.data.data.forEach(character => { - let aliases: string[] = []; - if (character.alias) { - if (typeof character.alias === 'string') { - aliases.push(character.alias); - } else if (Array.isArray(character.alias)) { - aliases = aliases.concat(character.alias); - } - } - - charactersArray.push({ - id: character.id, - name: character.name, - alias: aliases.length > 0 ? aliases.join(', ') : Strings.varStrings.varNone, - url: character.url, - sex: character.sex, - residence: character.residence ? character.residence.replace(/\n/g, ' / ') : Strings.varStrings.varNone, - occupation: character.occupation ? character.occupation.replace(/\n/g, ' / ') : Strings.varStrings.varNone, - kind: character.kind ? character.kind.join(', ') : Strings.varStrings.varNone, - image: character.image - }); - }); - }; - - if (charactersArray.length > 0) { + const data = response.data.data; + if (Array.isArray(data) && data.length > 0) { + const character = data[0]; + const aliases = Array.isArray(character.alias) + ? character.alias.join(', ') + : character.alias || Strings.varStrings.varNone; const result = Strings.ponyApi.charRes - .replace("{id}", charactersArray[0].id) - .replace("{name}", charactersArray[0].name) - .replace("{alias}", charactersArray[0].alias) - .replace("{url}", charactersArray[0].url) - .replace("{sex}", charactersArray[0].sex) - .replace("{residence}", charactersArray[0].residence) - .replace("{occupation}", charactersArray[0].occupation) - .replace("{kind}", charactersArray[0].kind); - - ctx.replyWithPhoto(charactersArray[0].image[0], { - caption: `${result}`, - parse_mode: 'Markdown', - ...({ reply_to_message_id, disable_web_page_preview: true }) - }); + .replace("{id}", character.id) + .replace("{name}", character.name) + .replace("{alias}", aliases) + .replace("{url}", character.url) + .replace("{sex}", character.sex) + .replace("{residence}", character.residence ? character.residence.replace(/\n/g, ' / ') : Strings.varStrings.varNone) + .replace("{occupation}", character.occupation ? character.occupation.replace(/\n/g, ' / ') : Strings.varStrings.varNone) + .replace("{kind}", Array.isArray(character.kind) ? character.kind.join(', ') : Strings.varStrings.varNone); + sendPhoto(ctx, character.image[0], result, reply_to_message_id); } else { - ctx.reply(Strings.ponyApi.noCharFound, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - }; - } catch (error) { - const message = Strings.ponyApi.apiErr.replace('{error}', error.message); - ctx.reply(message, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - }; + sendReply(ctx, Strings.ponyApi.noCharFound, reply_to_message_id); + } + } catch (error: any) { + const message = Strings.ponyApi.apiErr.replace('{error}', error.message || 'Unknown error'); + sendReply(ctx, message, reply_to_message_id); + } }); bot.command("mlpep", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { @@ -157,10 +128,10 @@ export default (bot: Telegraf) => { return; } - if (Number(userInput) > 100) { + if (Number(userInput) > 10000) { ctx.reply(Strings.mlpInvalidEpisode, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); return; } @@ -205,21 +176,19 @@ export default (bot: Telegraf) => { ctx.replyWithPhoto(episodeArray[0].image, { caption: `${result}`, parse_mode: 'Markdown', - ...({ reply_to_message_id, disable_web_page_preview: true }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } else { ctx.reply(Strings.ponyApi.noEpisodeFound, { parse_mode: 'Markdown', - - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; } catch (error) { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', - - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; }); @@ -239,7 +208,7 @@ export default (bot: Telegraf) => { if (/[^a-zA-Z\s]/.test(userInput) || userInput.length > 30) { ctx.reply(Strings.mlpInvalidCharacter, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); return; } @@ -289,21 +258,19 @@ export default (bot: Telegraf) => { ctx.replyWithPhoto(comicArray[0].image, { caption: `${result}`, parse_mode: 'Markdown', - ...({ reply_to_message_id, disable_web_page_preview: true }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } else { ctx.reply(Strings.ponyApi.noComicFound, { parse_mode: 'Markdown', - - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; } catch (error) { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', - - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; }); diff --git a/src/commands/randompony.ts b/src/commands/randompony.ts index 175f283..de24016 100644 --- a/src/commands/randompony.ts +++ b/src/commands/randompony.ts @@ -9,39 +9,40 @@ import { replyToMessageId } from '../utils/reply-to-message-id'; const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); -export default (bot: Telegraf) => { - // TODO: this would greatly benefit from a loading message - bot.command(["rpony", "randompony", "mlpart"], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { - const Strings = getStrings(languageCode(ctx)); - const reply_to_message_id = replyToMessageId(ctx); - ctx.reply(Strings.ponyApi.searching, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - try { - const response = await axios(Resources.randomPonyApi); - let tags: string[] = []; - - if (response.data.pony.tags) { - if (typeof response.data.pony.tags === 'string') { - tags.push(response.data.pony.tags); - } else if (Array.isArray(response.data.pony.tags)) { - tags = tags.concat(response.data.pony.tags); - } - } - - ctx.replyWithPhoto(response.data.pony.representations.full, { - caption: `${response.data.pony.sourceURL}\n\n${tags.length > 0 ? tags.join(', ') : ''}`, - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - } catch (error) { - const message = Strings.ponyApi.apiErr.replace('{error}', error.message); - ctx.reply(message, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - return; - } +export const randomponyHandler = async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); + ctx.reply(Strings.ponyApi.searching, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); + try { + const response = await axios(Resources.randomPonyApi); + let tags: string[] = []; + + if (response.data.pony.tags) { + if (typeof response.data.pony.tags === 'string') { + tags.push(response.data.pony.tags); + } else if (Array.isArray(response.data.pony.tags)) { + tags = tags.concat(response.data.pony.tags); + } + } + + ctx.replyWithPhoto(response.data.pony.representations.full, { + caption: `${response.data.pony.sourceURL}\n\n${tags.length > 0 ? tags.join(', ') : ''}`, + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } catch (error) { + const message = Strings.ponyApi.apiErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + return; + } +}; + +export default (bot: Telegraf) => { + bot.command(["rpony", "randompony", "mlpart"], spamwatchMiddleware, randomponyHandler); } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..208bc56 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,23 @@ +import { + integer, + pgTable, + varchar, + timestamp, + boolean, + real +} from "drizzle-orm/pg-core"; + +export const usersTable = pgTable("users", { + telegramId: varchar({ length: 255 }).notNull().primaryKey(), + username: varchar({ length: 255 }).notNull(), + firstName: varchar({ length: 255 }).notNull(), + lastName: varchar({ length: 255 }).notNull(), + aiEnabled: boolean().notNull().default(false), + customAiModel: varchar({ length: 255 }).notNull().default("deepseek-r1:1.5b"), + aiTemperature: real().notNull().default(0.9), + aiRequests: integer().notNull().default(0), + aiCharacters: integer().notNull().default(0), + languageCode: varchar({ length: 255 }).notNull(), + createdAt: timestamp().notNull().defaultNow(), + updatedAt: timestamp().notNull().defaultNow(), +}); diff --git a/src/locales/config.ts b/src/locales/config.ts new file mode 100644 index 0000000..7da7d37 --- /dev/null +++ b/src/locales/config.ts @@ -0,0 +1,4 @@ +export const langs = [ + { code: 'en', label: 'English' }, + { code: 'pt', label: 'Português' } +]; \ No newline at end of file diff --git a/src/locales/english.json b/src/locales/english.json index fadfcd6..e1ad103 100644 --- a/src/locales/english.json +++ b/src/locales/english.json @@ -1,4 +1,5 @@ { + "userNotFound": "User not found.", "botWelcome": "*Hello! I'm {botName}!*\nI was made with love by some nerds who really love programming!\n\n*By using {botName}, you affirm that you have read to and agree with the privacy policy (/privacy). This helps you understand where your data goes when using this bot.*\n\nAlso, you can use /help to see the bot commands!", "botHelp": "*Hey, I'm {botName}, a simple bot made entirely from scratch in Telegraf and Node.js by some nerds who really love programming.*\n\nCheck out the source code: [Click here to go to GitHub]({sourceLink})\n\nClick on the buttons below to see which commands you can use!\n", "botPrivacy": "Check out [this link]({botPrivacy}) to read the bot's privacy policy.", @@ -53,7 +54,7 @@ "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", + "mainCommandsDesc": "ℹ️ *Main Commands*\n\n- /help: Show bot's help\n- /start: Start the bot\n- /privacy: Read the bot's Privacy Policy\n- /settings: Show your user settings", "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", @@ -62,8 +63,15 @@ "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", + "ai": { + "helpEntry": "✨ AI Commands", + "helpDesc": "✨ *AI Commands*\n\n- /ask ``: Ask a question to an AI\n- /think ``: Ask a thinking model about a question", + "disabled": "✨ AI features are currently disabled", + "pulling": "🔄 *Pulling {model} from Ollama...*\n\nThis may take a few minutes...", + "askGenerating": "✨ _{model} is working..._", + "askNoMessage": "Please provide a message to ask the model.", + "languageCode": "Language" + }, "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": { @@ -81,6 +89,33 @@ "noLink": "Please provide a link to a video to download.", "botDetection": "My server is being rate limited by the video provider! Please try again later, or ask the bot owner to add their cookies/account." }, + "settings": { + "helpEntry": "🔧 Settings", + "helpDesc": "🔧 *Settings*\n\n- /settings: Show your settings", + "mainSettings": "🔧 *Settings*\n\n- AI Enabled: {aiEnabled}\n- /ai Custom Model: {aiModel}\n- AI Temperature: {aiTemperature}\n- Total AI Requests: {aiRequests}\n- Total AI Characters Sent/Recieved: {aiCharacters}\n- Language: {languageCode}", + "enabled": "Enabled", + "disabled": "Disabled", + "selectSetting": "Please select a setting to modify or view.", + "ai": { + "aiEnabled": "AI Enabled", + "aiModel": "AI Model", + "aiTemperature": "AI Temperature", + "aiRequests": "Total AI Requests", + "aiCharacters": "Total AI Characters Sent/Recieved", + "languageCode": "Language", + "aiEnabledSetTo": "AI Enabled set to {aiEnabled}", + "aiModelSetTo": "AI Model set to {aiModel}", + "aiTemperatureSetTo": "AI Temperature set to {aiTemperature}", + "back": "Back", + "selectSeries": "Please select a model series.", + "seriesDescription": "{seriesDescription}", + "selectParameterSize": "Please select a parameter size for {seriesLabel}.", + "parameterSizeExplanation": "Parameter size (e.g. 2B, 4B) refers to the number of parameters in the model. Larger models may be more capable but require more resources.", + "modelSetTo": "Model set to {aiModel} ({parameterSize})" + }, + "languageCodeSetTo": "Language set to {languageCode}", + "unknownAction": "Unknown action." + }, "botUpdated": "Bot updated with success.\n\n```{result}```", "errorUpdatingBot": "Error updating bot\n\n{error}", "catImgErr": "Sorry, but I couldn't get the cat photo you wanted.", @@ -120,6 +155,13 @@ }, "chatNotFound": "Chat not found.", "noFileProvided": "Please provide a file to send.", - "askGenerating": "✨ _{model} is working..._", - "aiDisabled": "AI features are currently disabled" + "gsmarenaProvidePhoneName": "Please provide the phone name.", + "gsmarenaSearchingFor": "Searching for `{phone}`...", + "gsmarenaNoPhonesFound": "No phones found for `{phone}`.", + "gsmarenaNoPhonesFoundBoth": "No phones found for `{name}` and `{phone}`.", + "gsmarenaSelectDevice": "Please select your device:", + "gsmarenaNotAllowed": "you are not allowed to interact with this.", + "gsmarenaInvalidOrExpired": "Whoops, invalid or expired option. Please try again.", + "gsmarenaDeviceDetails": "these are the details of your device:", + "gsmarenaErrorFetchingDetails": "Error fetching phone details." } \ No newline at end of file diff --git a/src/locales/portuguese.json b/src/locales/portuguese.json index 415eeb1..63b3a4c 100644 --- a/src/locales/portuguese.json +++ b/src/locales/portuguese.json @@ -33,8 +33,8 @@ "funEmojiResult": "*Você lançou {emoji} e obteve *`{value}`*!*\nVocê não sabe o que isso significa? Nem eu!", "gifErr": "*Algo deu errado ao enviar o GIF. Tente novamente mais tarde.*\n\n{err}", "lastFm": { - "helpEntry": "Last.fm", - "helpDesc": "*Last.fm*\n\n- /lt | /lmu | /last | /lfm: Mostra a última música do seu perfil no Last.fm + o número de reproduções.\n- /setuser ``: Define o usuário para o comando acima.", + "helpEntry": "🎵 Last.fm", + "helpDesc": "🎵 *Last.fm*\n\n- /lt | /lmu | /last | /lfm: Mostra a última música do seu perfil no Last.fm + o número de reproduções.\n- /setuser ``: Define o usuário para o comando acima.", "noUser": "*Por favor, forneça um nome de usuário do Last.fm.*\nExemplo: `/setuser `", "noUserSet": "*Você ainda não definiu seu nome de usuário do Last.fm.*\nUse o comando /setuser para definir.\n\nExemplo: `/setuser `", "noRecentTracks": "*Nenhuma faixa recente encontrada para o usuário do Last.fm* `{lastfmUser}`*.*", @@ -52,27 +52,34 @@ "apiErr": "*Ocorreu um erro ao obter o clima. Tente novamente mais tarde.*\n\n`{error}`", "apiKeyErr": "*Uma chave de API não foi definida pelo proprietário do bot. Tente novamente mais tarde.*" }, - "mainCommands": "Comandos principais", - "mainCommandsDesc": "*Comandos principais*\n\n- /help: Exibe a ajuda do bot\n- /start: Inicia o bot\n- /privacy: Leia a política de privacidade do bot", - "usefulCommands": "Comandos úteis", - "usefulCommandsDesc": "*Comandos úteis*\n\n- /chatinfo: Envia informações sobre o grupo\n- /userinfo: Envia informações sobre você\n- /d | /device ``: Pesquisa um dispositivo no GSMArena e mostra suas especificações.\n- /weather | /clima ``: Veja o status do clima para uma localização específica\n- /modarchive | /tma ``: Baixa um módulo do The Mod Archive.\n- /http ``: Envia detalhes sobre um código HTTP específico. Exemplo: `/http 404`", - "funnyCommands": "Comandos engraçados", + "mainCommands": "ℹ️ Comandos principais", + "mainCommandsDesc": "ℹ️ *Comandos principais*\n\n- /help: Exibe a ajuda do bot\n- /start: Inicia o bot\n- /privacy: Leia a política de privacidade do bot\n- /settings: Exibe suas configurações", + "usefulCommands": "🛠️ Comandos úteis", + "usefulCommandsDesc": "🛠️ *Comandos úteis*\n\n- /chatinfo: Envia informações sobre o grupo\n- /userinfo: Envia informações sobre você\n- /d | /device ``: Pesquisa um dispositivo no GSMArena e mostra suas especificações.\n- /weather | /clima ``: Veja o status do clima para uma localização específica\n- /modarchive | /tma ``: Baixa um módulo do The Mod Archive.\n- /http ``: Envia detalhes sobre um código HTTP específico. Exemplo: `/http 404`", + "funnyCommands": "😂 Comandos engraçados", "funnyCommandsDesc": "*Comandos engraçados*\n\n- /gay: Verifique se você é gay\n- /furry: Verifique se você é furry\n- /random: Escolhe um número aleatório entre 0-10", - "interactiveEmojis": "Emojis interativos", - "interactiveEmojisDesc": "*Emojis interativos*\n\n- /dice: Jogue um dado\n- /idice: Role infinitamente um dado colorido\n- /slot: Tente combinar as figuras!\n- /ball: Tente chutar a bola no gol!\n- /bowling: Tente derrubar os pinos!\n- /dart: Tente acertar o alvo!", - "animalCommands": "Animais", - "animalCommandsDesc": "*Animais*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Envia o [meme do gato encharcado](https://knowyourmeme.com/memes/soggy-cat)\n- /cat - Envia uma foto aleatória de um gato.\n- /fox - Envia uma foto aleatória de uma raposa.\n- /duck - Envia uma foto aleatória de um pato.\n- /dog - Envia uma imagem aleatória de um cachorro.\n- /httpcat ``: Envia memes de gato do http.cat com o código HTTP especificado. Exemplo: `/httpcat 404`", - "aiCmds": "Comandos de IA", - "aiCmdsDesc": "*Comandos de IA*\n\n- /ask ``: Fazer uma pergunta a uma IA", + "interactiveEmojis": "🎲 Emojis interativos", + "interactiveEmojisDesc": "🎲 *Emojis interativos*\n\n- /dice: Jogue um dado\n- /idice: Role infinitamente um dado colorido\n- /slot: Tente combinar as figuras!\n- /ball: Tente chutar a bola no gol!\n- /bowling: Tente derrubar os pinos!\n- /dart: Tente acertar o alvo!", + "animalCommands": "🐱 Animais", + "animalCommandsDesc": "🐱 *Animais*\n\n- /soggy | /soggycat `<1 | 2 | 3 | 4 | orig | thumb | sticker | alt>`: Envia o [meme do gato encharcado](https://knowyourmeme.com/memes/soggy-cat)\n- /cat - Envia uma foto aleatória de um gato.\n- /fox - Envia uma foto aleatória de uma raposa.\n- /duck - Envia uma foto aleatória de um pato.\n- /dog - Envia uma imagem aleatória de um cachorro.\n- /httpcat ``: Envia memes de gato do http.cat com o código HTTP especificado. Exemplo: `/httpcat 404`", + "ai": { + "helpEntry": "✨ Comandos de IA", + "helpDesc": "✨ *Comandos de IA*\n\n- /ask ``: Fazer uma pergunta a uma IA\n- /think ``: Fazer uma pergunta a um modelo de pensamento", + "disabled": "✨ Os recursos de IA estão desativados no momento", + "pulling": "🔄 *Puxando {model} do Ollama...*\n\nIsso pode levar alguns minutos...", + "askGenerating": "✨ _{model} está funcionando..._", + "askNoMessage": "Por favor, forneça uma mensagem para fazer a pergunta ao modelo.", + "languageCode": "Idioma" + }, "maInvalidModule": "Por favor, forneça um ID de módulo válido do The Mod Archive.\nExemplo: `/modarchive 81574`", "maDownloadError": "Erro ao baixar o arquivo. Verifique o ID do módulo e tente novamente.", "ytDownload": { - "helpEntry": "Download de vídeos", - "helpDesc": "*Download de vídeos*\n\n- /yt | /ytdl | /sdl | /dl | /video ``: Baixa um vídeo de algumas plataformas (ex: YouTube, Instagram, Facebook, etc.).\n\nConsulte [este link](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) para obter mais informações e saber quais serviços são compatíveis.\n\n*Nota: O Telegram está atualmente limitando os uploads de bots a 50MB, o que significa que se o vídeo que você deseja baixar for maior que 50MB, a qualidade será reduzida para tentar carregá-lo de qualquer maneira. Estamos fazendo o possível para contornar ou corrigir esse problema.*", - "downloadingVid": "*Baixando vídeo...*", + "helpEntry": "📺 Download de vídeos", + "helpDesc": "📺 *Download de vídeos*\n\n- /yt | /ytdl | /sdl | /dl | /video ``: Baixa um vídeo de algumas plataformas (ex: YouTube, Instagram, Facebook, etc.).\n\nConsulte [este link](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) para obter mais informações e saber quais serviços são compatíveis.\n\n*Nota: O Telegram está atualmente limitando os uploads de bots a 50MB, o que significa que se o vídeo que você deseja baixar for maior que 50MB, a qualidade será reduzida para tentar carregá-lo de qualquer maneira. Estamos fazendo o possível para contornar ou corrigir esse problema.*", + "downloadingVid": "⬇️ *Baixando vídeo...*", "libNotFound": "*Parece que o executável do yt-dlp não existe no nosso servidor...\n\nNesse caso, o problema está no nosso lado! Aguarde até que tenhamos notado e resolvido o problema.*", - "checkingSize": "Verificando se o vídeo excede o limite de 50 MB...", - "uploadingVid": "*Enviando vídeo...*", + "checkingSize": "🔎 *Verificando se o vídeo excede o limite de 50 MB...*", + "uploadingVid": "⬆️ *Enviando vídeo...*", "msgDesc": "{userMention}*, aqui está o seu vídeo baixado.*", "downloadErr": "*Erro durante o download do vídeo do YT:*\n\n`{err}`", "uploadErr": "Erro ao enviar o arquivo. Tente novamente mais tarde.", @@ -81,6 +88,33 @@ "noLink": "*Por favor, forneça um link de um vídeo para download.*", "botDetection": "Meu servidor está com a taxa limitada pelo provedor de vídeo! Tente novamente mais tarde ou peça ao proprietário do bot para adicionar seus cookies/conta." }, + "settings": { + "helpEntry": "🔧 Configurações", + "helpDesc": "🔧 *Configurações*\n\n- /settings: Mostrar suas configurações", + "mainSettings": "🔧 *Configurações*\n\n- Inteligência Artificial Ativado: {aiEnabled}\n- /ai Modelo personalizado: {aiModel}\n- Inteligência Artificial Temperatura: {aiTemperature}\n- Total de Requests: {aiRequests}\n- Total de Caracteres Enviados/Recebidos: {aiCharacters}\n- Idioma: {languageCode}", + "enabled": "Ativado", + "disabled": "Desativado", + "selectSetting": "Por favor, selecione uma configuração para modificar ou visualizar.", + "ai": { + "aiEnabled": "IA", + "aiModel": "Modelo", + "aiTemperature": "Temperatura", + "aiRequests": "Total de Requests", + "aiCharacters": "Total de Caracteres Enviados/Recebidos", + "languageCode": "Idioma", + "aiEnabledSetTo": "Inteligência Artificial definido para {aiEnabled}", + "aiModelSetTo": "Modelo personalizado definido para {aiModel}", + "aiTemperatureSetTo": "Temperatura definida para {aiTemperature}", + "selectSeries": "Por favor, selecione uma série de modelos.", + "seriesDescription": "{seriesDescription}", + "selectParameterSize": "Por favor, selecione um tamanho de parâmetro para {seriesLabel}.", + "parameterSizeExplanation": "O tamanho do parâmetro (ex: 2B, 4B) refere-se ao número de parâmetros do modelo. Modelos maiores podem ser mais capazes, mas exigem mais recursos.", + "modelSetTo": "Modelo definido para {aiModel} ({parameterSize})", + "back": "Voltar" + }, + "languageCodeSetTo": "Idioma definido para {languageCode}", + "unknownAction": "Ação desconhecida." + }, "botUpdated": "Bot atualizado com sucesso.\n\n```{result}```", "errorUpdatingBot": "Erro ao atualizar o bot\n\n{error}", "catImgErr": "Desculpe, mas não consegui obter a foto do gato que você queria.", @@ -97,8 +131,8 @@ "resultMsg": "*Código HTTP*: `{code}`\n*Nome*: `{message}`\n*Descrição*: `{description}`" }, "ponyApi": { - "helpEntry": "My Little Pony", - "helpDesc": "*My Little Pony*\n\n- /mlp: Exibe esta mensagem de ajuda.\n- /mlpchar ``: Mostra informações específicas sobre um personagem de My Little Pony em inglês. Exemplo: `/mlpchar twilight`\n- /mlpep: Mostra informações específicas sobre um episódio de My Little Pony em inglês. Exemplo: `/mlpep 136`\n- /mlpcomic ``: Mostra informações específicas sobre uma comic de My Little Pony em inglês. Exemplo: `/mlpcomic Nightmare Rarity`\n- /rpony | /randompony | /mlpart: Envia uma arte aleatória feita pela comunidade de My Little Pony.", + "helpEntry": "🐴 My Little Pony", + "helpDesc": "🐴 *My Little Pony*\n\n- /mlp: Exibe esta mensagem de ajuda.\n- /mlpchar ``: Mostra informações específicas sobre um personagem de My Little Pony em inglês. Exemplo: `/mlpchar twilight`\n- /mlpep: Mostra informações específicas sobre um episódio de My Little Pony em inglês. Exemplo: `/mlpep 136`\n- /mlpcomic ``: Mostra informações específicas sobre uma comic de My Little Pony em inglês. Exemplo: `/mlpcomic Nightmare Rarity`\n- /rpony | /randompony | /mlpart: Envia uma arte aleatória feita pela comunidade de My Little Pony.", "charRes": "*{name} (ID: {id})*\n\n*Apelido:* `{alias}`\n*Sexo:* `{sex}`\n*Residência:* `{residence}`\n*Ocupação:* `{occupation}`\n*Tipo:* `{kind}`\n\n*URL no Fandom:*\n[{url}]({url})", "epRes": "*{name} (ID: {id})*\n\n*Temporada:* `{season}`\n*Episódio:* `{episode}`\n*Número do Episódio:* `{overall}`\n*Data de lançamento:* `{airdate}`\n*História por:* `{storyby}`\n*Escrito por:* `{writtenby}`\n*Storyboard:* `{storyboard}`\n\n*URL no Fandom:*\n[{url}]({url})", "comicRes": "*{name} (ID: {id})*\n\n*Série:* `{series}`\n*Roteirista:* `{writer}`\n*Artista:* `{artist}`\n*Colorista:* `{colorist}`\n*Letrista:* `{letterer}`\n*Editor:* `{editor}`\n\n*URL no Fandom:*\n[{url}]({url})", @@ -119,6 +153,14 @@ "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" + "gsmarenaProvidePhoneName": "Por favor, forneça o nome do celular.", + "gsmarenaSearchingFor": "Procurando por `{phone}`...", + "gsmarenaNoPhonesFound": "Nenhum celular encontrado para `{phone}`.", + "gsmarenaNoPhonesFoundBoth": "Nenhum celular encontrado para `{name}` e `{phone}`.", + "gsmarenaSelectDevice": "Por favor, selecione seu dispositivo:", + "gsmarenaNotAllowed": "você não tem permissão para interagir com isso.", + "gsmarenaInvalidOrExpired": "Ops! Opção inválida ou expirada. Por favor, tente novamente.", + "gsmarenaDeviceDetails": "estes são os detalhes do seu dispositivo:", + "gsmarenaErrorFetchingDetails": "Erro ao buscar detalhes do celular.", + "userNotFound": "Usuário não encontrado." } diff --git a/src/utils/ensure-user.ts b/src/utils/ensure-user.ts new file mode 100644 index 0000000..0654476 --- /dev/null +++ b/src/utils/ensure-user.ts @@ -0,0 +1,64 @@ +// ENSURE-USER.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 { usersTable } from '../db/schema'; + +export async function ensureUserInDb(ctx, db) { + if (!ctx.from) return; + const telegramId = String(ctx.from.id); + const username = ctx.from.username || ''; + const firstName = ctx.from.first_name || ' '; + const lastName = ctx.from.last_name || ' '; + const languageCode = ctx.from.language_code || 'en'; + + const existing = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, telegramId), limit: 1 }); + if (existing.length === 0) { + const userToInsert = { + telegramId, + username, + firstName, + lastName, + languageCode, + aiEnabled: false, + customAiModel: "deepseek-r1:1.5b", + aiTemperature: 0.9, + aiRequests: 0, + aiCharacters: 0, + }; + console.log('[💽 DB] Inserting user with values:', userToInsert); + try { + await db.insert(usersTable).values(userToInsert); + console.log(`[💽 DB] Added new user: ${username || firstName} (${telegramId})`); + } catch (err) { + console.error('[💽 DB] Error inserting user:', err); + throw err; + } + } +} diff --git a/src/utils/log.ts b/src/utils/log.ts index 67019a8..2f7e9ac 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -63,19 +63,24 @@ class Logger { console.log(`[✨ AI | PROMPT] ${prompt.length} chars input`) } - logError(error: any): void { - if (error.response?.error_code === 429) { - const retryAfter = error.response.parameters?.retry_after || 1 - 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("[✨ 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 + logError(error: unknown): void { + if (typeof error === 'object' && error !== null && 'response' in error) { + const err = error as { response?: { error_code?: number, parameters?: { retry_after?: number }, description?: string }, on?: { method?: string } }; + if (err.response?.error_code === 429) { + const retryAfter = err.response.parameters?.retry_after || 1; + console.error(`[✨ AI | RATE_LIMIT] Too Many Requests - retry after ${retryAfter}s`); + } else if (err.response?.error_code === 400 && err.response?.description?.includes("can't parse entities")) { + console.error("[✨ AI | PARSE_ERROR] Markdown parsing failed, retrying with plain text"); + } else { + const errorDetails = { + code: err.response?.error_code, + description: err.response?.description, + method: err.on?.method + }; + console.error("[✨ AI | ERROR]", JSON.stringify(errorDetails, null, 2)); } - console.error("[✨ AI | ERROR]", JSON.stringify(errorDetails, null, 2)) + } else { + console.error("[✨ AI | ERROR]", error); } } } diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts index 777bb4f..b65ebb2 100644 --- a/src/utils/rate-limiter.ts +++ b/src/utils/rate-limiter.ts @@ -90,7 +90,14 @@ class RateLimiter { return chunks } - private handleTelegramError(error: unknown, messageKey: string, options: any, ctx: Context, chatId: number, messageId: number): boolean { + private handleTelegramError( + error: unknown, + messageKey: string, + options: Record, + 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 @@ -130,7 +137,7 @@ class RateLimiter { ctx: Context, chatId: number, messageId: number, - options: any + options: Record ): Promise { const messageKey = this.getMessageKey(chatId, messageId) const latestText = this.pendingUpdates.get(messageKey) @@ -184,7 +191,7 @@ class RateLimiter { const newMessage = await ctx.telegram.sendMessage(chatId, chunk, { ...options, reply_to_message_id: messageId - }) + } as any) logger.logChunk(chatId, newMessage.message_id, chunk, true) this.overflowMessages.set(messageKey, newMessage.message_id) } @@ -226,7 +233,7 @@ class RateLimiter { chatId: number, messageId: number, text: string, - options: any + options: Record ): Promise { const messageKey = this.getMessageKey(chatId, messageId) this.pendingUpdates.set(messageKey, text)