index.ts 9.1 KB

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