index.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. import { Database } from "bun:sqlite";
  2. import { Bot, Context } from "grammy";
  3. const TOKEN = Bun.env.TELEGRAM_TOKEN!;
  4. const TELEGRAM_SUPPORT_CHAT_ID = Bun.env.TELEGRAM_SUPPORT_CHAT_ID!;
  5. const APP_NAME = Bun.env.APP_NAME!;
  6. const TICKETS_LIST_LIMIT = 10;
  7. const TICKET_CODE_REGEX = /^#T\d+$/;
  8. const DB_CLEANUP_INTERVAL = 24 * 60 * 60 * 60 * 1000; // Day
  9. const db = new Database('./db/tickets.db');
  10. db.run(`
  11. CREATE TABLE IF NOT EXISTS counters (
  12. key TEXT NOT NULL PRIMARY KEY,
  13. counter INTEGER DEFAULT 0
  14. );
  15. INSERT INTO counters (key, counter) VALUES ('last_ticket_id', 0) ON CONFLICT(key) DO NOTHING;
  16. CREATE TABLE IF NOT EXISTS tickets (
  17. id INTEGER PRIMARY KEY NOT NULL,
  18. chat_id TEXT NOT NULL,
  19. first_message TEXT NOT NULL,
  20. status TEXT NOT NULL,
  21. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  22. closed_at TIMESTAMP NULL
  23. );
  24. CREATE INDEX IF NOT EXISTS chat_id_idx ON tickets (chat_id)`);
  25. type Ticket = {
  26. id: number;
  27. chat_id: string;
  28. first_message: string;
  29. status: 'open' | 'closed';
  30. created_at: string;
  31. closed_at: string | null;
  32. };
  33. const bot = new Bot(TOKEN);
  34. const supportChatFilter = async (ctx: Context) => {
  35. const chat = await ctx.getChat();
  36. if (String(chat.id) !== TELEGRAM_SUPPORT_CHAT_ID) {
  37. await ctx.reply(`Этот бот доступен только для сервиса "${APP_NAME}"`);
  38. return false;
  39. }
  40. return true;
  41. }
  42. const getOpenedUserTicket = db.prepare<Ticket, string>('SELECT * FROM tickets WHERE chat_id = ? AND status = "open"');
  43. const updateCounterAndGet = db.prepare<{ counter: number }, []>(`UPDATE counters SET counter = counter + 1 WHERE key = 'last_ticket_id' RETURNING counter`);
  44. const createTicketAndReturn = db.prepare<Ticket, [number, string, string]>(`
  45. INSERT INTO tickets (
  46. id, chat_id, first_message, status
  47. ) VALUES (
  48. ?,
  49. ?,
  50. ?,
  51. 'open'
  52. ) RETURNING *`);
  53. 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 ?');
  54. const getOpenedTicketById = db.prepare<Ticket, number>('SELECT * FROM tickets WHERE id = ? AND status = "open"');
  55. const closeTicket = db.prepare<{ id: number }, number>('UPDATE tickets SET status = "closed", closed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = "open" RETURNING id');
  56. const getClosedTicketsCount = db.query<{ count: number }, []>('SELECT count(*) as count FROM tickets WHERE status = "closed"');
  57. let blockUpdateDb = false;
  58. // Clear db
  59. setInterval(() => {
  60. blockUpdateDb = true;
  61. db.run('DELETE FROM tickets WHERE status = "closed"');
  62. const lastTicket = db.query<Ticket, []>('SELECT * FROM tickets ORDER BY created_at DESC LIMIT 1').get();
  63. const newCounter = lastTicket?.id ?? 0;
  64. db.query<null, number>('UPDATE counters SET counter = ? WHERE key = "last_ticket_id"').run(newCounter);
  65. console.log(new Date(), 'DB cleared. New counter is', newCounter);
  66. blockUpdateDb = false;
  67. }, DB_CLEANUP_INTERVAL)
  68. const notifyTicketCreated = async (ticket: Ticket) => {
  69. const code = `#T${ticket.id}`;
  70. await bot.api.sendMessage(TELEGRAM_SUPPORT_CHAT_ID, `Новое обращение ${code}
  71. Время регистрации ${ticket.created_at}
  72. Сообщение:
  73. ${ticket.first_message}
  74. Чтобы ответить используйте \`/answer ${code} текст\`. Чтобы закрыть тикет используйте \`/close ${code}\`. Закрытые тикеты через время удаляются`, { parse_mode: 'Markdown' });
  75. }
  76. const notifyNewTicketMessage = async (ticket: Ticket, message: string) => {
  77. const code = `#T${ticket.id}`;
  78. await bot.api.sendMessage(TELEGRAM_SUPPORT_CHAT_ID, `${code}
  79. ${message}
  80. Чтобы ответить используйте \`/answer ${code} текст\`. Чтобы закрыть тикет используйте \`/close ${code}\`.
  81. `, { parse_mode: 'Markdown' });
  82. }
  83. const chatBot = bot.chatType('private');
  84. chatBot.command('start', (ctx) => ctx.reply(`👋 Привет! Это бот службы поддержки приложения ${APP_NAME}! Вы можете написать нам ваши предложения по улучшению приложения или пожаловаться на проблемы при работе с ним. Наши менеджеры вам ответят.`));
  85. chatBot.on('message', async (ctx) => {
  86. if (blockUpdateDb) return ctx.reply('🛠️ Просим прощения, идут технические работы. Пока вы читали это сообщение, мы уже закончили! Пожалуйста, повторите свой запрос.');
  87. const { id } = await ctx.getChat();
  88. const messageText = ctx.message?.text;
  89. if (!messageText) return;
  90. const chatId = String(id);
  91. const ticket = getOpenedUserTicket.get(chatId);
  92. if (!ticket) {
  93. const newId = updateCounterAndGet.get();
  94. if (!newId) throw new Error('Не удалось добавить тикет');
  95. const newTicket = createTicketAndReturn.get(newId.counter, chatId, messageText);
  96. if (!newTicket) throw new Error('Не удалось добавить тикет');
  97. await notifyTicketCreated(newTicket);
  98. ctx.reply('📨 Ваше сообщение отправлено сотрудникам службы поддержки. Мы скоро вам ответим в этом чате!');
  99. return;
  100. }
  101. await notifyNewTicketMessage(ticket, messageText);
  102. });
  103. const groupBot = bot.chatType('group').filter(supportChatFilter);
  104. groupBot.command('help', (ctx) => {
  105. ctx.reply(`Привет! Вам доступны следующие команды:
  106. \`/answer #ID текст\` - ответить по тикету
  107. \`/close #ID\` - закрыть тикет и отправить клиенту сообщение с благодарностью за обрашение
  108. /closed - показать количество закрытых тикетов
  109. /tickets - показать все открытые тикеты`, { parse_mode: 'Markdown' });
  110. });
  111. groupBot.command('tickets', (ctx) => {
  112. const tickets = getTickets.all(TICKETS_LIST_LIMIT + 1);
  113. if (!tickets.length) return ctx.reply('Нет открытых тикетов');
  114. let answer = tickets.map((ticket) => `#T${ticket.id} ${ticket.created_at}`).join('\n');
  115. if (tickets.length > TICKETS_LIST_LIMIT) {
  116. answer += `\n...`;
  117. }
  118. ctx.reply(answer);
  119. });
  120. groupBot.command('close', (ctx) => {
  121. if (!TICKET_CODE_REGEX.test(ctx.match)) return ctx.reply('Неверный формат команды. Укажите код тикета в таком формате: \`#T0\`', { parse_mode: 'Markdown' });
  122. const id = ctx.match.slice(2);
  123. const idNumber = Number.parseInt(id);
  124. const ticket = getOpenedTicketById.get(idNumber);
  125. if (!ticket) return ctx.reply(`Тикет ${ctx.match} не найден или уже закрыт`);
  126. const removedId = closeTicket.get(idNumber);
  127. if (removedId?.id !== idNumber) return ctx.reply(`Не удалось закрыть тикет ${ctx.match}`);
  128. ctx.reply(`Тикет ${ctx.match} закрыт`);
  129. bot.api.sendMessage(ticket.chat_id, `🎉 Работа по вашему обращению завершена.
  130. Благодарим за обращение! Вы всегда можете написать нам снова!`);
  131. });
  132. groupBot.command('answer', (ctx) => {
  133. const ticketIdMatch = ctx.match.match(/^#T\d+/);
  134. if (!ticketIdMatch) return ctx.reply('Неверный формат команды. Укажите код тикета в таком формате: \`#T0\` и через пробел напишите свое сообщение', { parse_mode: 'Markdown' });
  135. const [ticketId] = ticketIdMatch;
  136. const message = ctx.match.slice(ticketId.length + 1);
  137. if (!message) return ctx.reply('Неверный формат команды. Вы не написали сообщение. Формат \`/answer #ID текст\`', { parse_mode: 'Markdown' });
  138. const id = ticketId.slice(2);
  139. const idNumber = Number.parseInt(id);
  140. const ticket = getOpenedTicketById.get(idNumber);
  141. if (!ticket) return ctx.reply(`Тикет ${ticketId} не найден или уже закрыт`);
  142. bot.api.sendMessage(ticket.chat_id, `_Ответ от сотрудника поддержки_
  143. ${message}`, { parse_mode: 'Markdown' });
  144. });
  145. groupBot.command('closed', (ctx) => {
  146. const result = getClosedTicketsCount.get();
  147. if (!result) return ctx.reply('Запрос в базу провален');
  148. ctx.reply(`Закрытых тикетов ${result.count}`);
  149. });
  150. bot.start();