import { Database } from "bun:sqlite"; import { Bot, Context } from "grammy"; const TOKEN = Bun.env.TELEGRAM_TOKEN!; const TELEGRAM_SUPPORT_CHAT_ID = Bun.env.TELEGRAM_SUPPORT_CHAT_ID!; const APP_NAME = Bun.env.APP_NAME!; const TICKETS_LIST_LIMIT = 10; const TICKET_CODE_REGEX = /^#T\d+$/; const DB_CLEANUP_INTERVAL = 24 * 60 * 60 * 60 * 1000; // Day const db = new Database('./db/tickets.db'); db.run(` CREATE TABLE IF NOT EXISTS counters ( key TEXT NOT NULL PRIMARY KEY, counter INTEGER DEFAULT 0 ); INSERT INTO counters (key, counter) VALUES ('last_ticket_id', 0) ON CONFLICT(key) DO NOTHING; CREATE TABLE IF NOT EXISTS tickets ( id INTEGER PRIMARY KEY NOT NULL, chat_id TEXT NOT NULL, first_message TEXT NOT NULL, status TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, closed_at TIMESTAMP NULL ); CREATE INDEX IF NOT EXISTS chat_id_idx ON tickets (chat_id)`); type Ticket = { id: number; chat_id: string; first_message: string; status: 'open' | 'closed'; created_at: string; closed_at: string | null; }; const bot = new Bot(TOKEN); const supportChatFilter = async (ctx: Context) => { const chat = await ctx.getChat(); if (String(chat.id) !== TELEGRAM_SUPPORT_CHAT_ID) { await ctx.reply(`Этот бот доступен только для сервиса "${APP_NAME}"`); return false; } return true; } const getOpenedUserTicket = db.prepare('SELECT * FROM tickets WHERE chat_id = ? AND status = "open"'); const updateCounterAndGet = db.prepare<{ counter: number }, []>(`UPDATE counters SET counter = counter + 1 WHERE key = 'last_ticket_id' RETURNING counter`); const createTicketAndReturn = db.prepare(` INSERT INTO tickets ( id, chat_id, first_message, status ) VALUES ( ?, ?, ?, 'open' ) RETURNING *`); const getTickets = db.query<{ id: number, created_at: string }, number>('SELECT id, created_at FROM tickets WHERE status = "open" ORDER BY created_at DESC LIMIT ?'); const getOpenedTicketById = db.prepare('SELECT * FROM tickets WHERE id = ? AND status = "open"'); const closeTicket = db.prepare<{ id: number }, number>('UPDATE tickets SET status = "closed", closed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = "open" RETURNING id'); const getClosedTicketsCount = db.query<{ count: number }, []>('SELECT count(*) as count FROM tickets WHERE status = "closed"'); let blockUpdateDb = false; // Clear db setInterval(() => { blockUpdateDb = true; db.run('DELETE FROM tickets WHERE status = "closed"'); const lastTicket = db.query('SELECT * FROM tickets ORDER BY created_at DESC LIMIT 1').get(); const newCounter = lastTicket?.id ?? 0; db.query('UPDATE counters SET counter = ? WHERE key = "last_ticket_id"').run(newCounter); console.log(new Date(), 'DB cleared. New counter is', newCounter); blockUpdateDb = false; }, DB_CLEANUP_INTERVAL) const notifyTicketCreated = async (ticket: Ticket) => { const code = `#T${ticket.id}`; await bot.api.sendMessage(TELEGRAM_SUPPORT_CHAT_ID, `Новое обращение ${code} Время регистрации ${ticket.created_at} Сообщение: ${ticket.first_message} Чтобы ответить используйте \`/answer ${code} текст\`. Чтобы закрыть тикет используйте \`/close ${code}\`. Закрытые тикеты через время удаляются`, { parse_mode: 'Markdown' }); } const notifyNewTicketMessage = async (ticket: Ticket, message: string) => { const code = `#T${ticket.id}`; await bot.api.sendMessage(TELEGRAM_SUPPORT_CHAT_ID, `${code} ${message} Чтобы ответить используйте \`/answer ${code} текст\`. Чтобы закрыть тикет используйте \`/close ${code}\`. `, { parse_mode: 'Markdown' }); } const chatBot = bot.chatType('private'); chatBot.command('start', (ctx) => ctx.reply(`👋 Привет! Это бот службы поддержки приложения ${APP_NAME}! Вы можете написать нам ваши предложения по улучшению приложения или пожаловаться на проблемы при работе с ним. Наши менеджеры вам ответят.`)); chatBot.on('message', async (ctx) => { if (blockUpdateDb) return ctx.reply('🛠️ Просим прощения, идут технические работы. Пока вы читали это сообщение, мы уже закончили! Пожалуйста, повторите свой запрос.'); const { id } = await ctx.getChat(); const messageText = ctx.message?.text; if (!messageText) return; const chatId = String(id); const ticket = getOpenedUserTicket.get(chatId); if (!ticket) { const newId = updateCounterAndGet.get(); if (!newId) throw new Error('Не удалось добавить тикет'); const newTicket = createTicketAndReturn.get(newId.counter, chatId, messageText); if (!newTicket) throw new Error('Не удалось добавить тикет'); await notifyTicketCreated(newTicket); ctx.reply('📨 Ваше сообщение отправлено сотрудникам службы поддержки. Мы скоро вам ответим в этом чате!'); return; } await notifyNewTicketMessage(ticket, messageText); }); const groupBot = bot.chatType('group').filter(supportChatFilter); groupBot.command('help', (ctx) => { ctx.reply(`Привет! Вам доступны следующие команды: \`/answer #ID текст\` - ответить по тикету \`/close #ID\` - закрыть тикет и отправить клиенту сообщение с благодарностью за обрашение /closed - показать количество закрытых тикетов /tickets - показать все открытые тикеты`, { parse_mode: 'Markdown' }); }); groupBot.command('tickets', (ctx) => { const tickets = getTickets.all(TICKETS_LIST_LIMIT + 1); if (!tickets.length) return ctx.reply('Нет открытых тикетов'); let answer = tickets.map((ticket) => `#T${ticket.id} ${ticket.created_at}`).join('\n'); if (tickets.length > TICKETS_LIST_LIMIT) { answer += `\n...`; } ctx.reply(answer); }); groupBot.command('close', (ctx) => { if (!TICKET_CODE_REGEX.test(ctx.match)) return ctx.reply('Неверный формат команды. Укажите код тикета в таком формате: \`#T0\`', { parse_mode: 'Markdown' }); const id = ctx.match.slice(2); const idNumber = Number.parseInt(id); const ticket = getOpenedTicketById.get(idNumber); if (!ticket) return ctx.reply(`Тикет ${ctx.match} не найден или уже закрыт`); const removedId = closeTicket.get(idNumber); if (removedId?.id !== idNumber) return ctx.reply(`Не удалось закрыть тикет ${ctx.match}`); ctx.reply(`Тикет ${ctx.match} закрыт`); bot.api.sendMessage(ticket.chat_id, `🎉 Работа по вашему обращению завершена. Благодарим за обращение! Вы всегда можете написать нам снова!`); }); groupBot.command('answer', (ctx) => { const ticketIdMatch = ctx.match.match(/^#T\d+/); if (!ticketIdMatch) return ctx.reply('Неверный формат команды. Укажите код тикета в таком формате: \`#T0\` и через пробел напишите свое сообщение', { parse_mode: 'Markdown' }); const [ticketId] = ticketIdMatch; const message = ctx.match.slice(ticketId.length + 1); if (!message) return ctx.reply('Неверный формат команды. Вы не написали сообщение. Формат \`/answer #ID текст\`', { parse_mode: 'Markdown' }); const id = ticketId.slice(2); const idNumber = Number.parseInt(id); const ticket = getOpenedTicketById.get(idNumber); if (!ticket) return ctx.reply(`Тикет ${ticketId} не найден или уже закрыт`); bot.api.sendMessage(ticket.chat_id, `_Ответ от сотрудника поддержки_ ${message}`, { parse_mode: 'Markdown' }); }); groupBot.command('closed', (ctx) => { const result = getClosedTicketsCount.get(); if (!result) return ctx.reply('Запрос в базу провален'); ctx.reply(`Закрытых тикетов ${result.count}`); }); bot.start();