|
|
@@ -7,10 +7,15 @@ 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_CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // Day
|
|
|
|
|
|
-const db = new Database('./db/tickets.db');
|
|
|
-db.run(`
|
|
|
+(async () => {
|
|
|
+ if (!(await Bun.file("./db/tickets.db").exists())) {
|
|
|
+ await Bun.write("./db/tickets.db", "");
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
@@ -25,29 +30,33 @@ CREATE TABLE IF NOT EXISTS tickets (
|
|
|
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<Ticket, string>('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<Ticket, [number, string, string]>(`
|
|
|
+ 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<Ticket, string>(
|
|
|
+ '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<Ticket, [number, string, string]>(`
|
|
|
INSERT INTO tickets (
|
|
|
id, chat_id, first_message, status
|
|
|
) VALUES (
|
|
|
@@ -56,122 +65,184 @@ const createTicketAndReturn = db.prepare<Ticket, [number, string, string]>(`
|
|
|
?,
|
|
|
'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<Ticket, number>('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<Ticket, []>('SELECT * FROM tickets ORDER BY created_at DESC LIMIT 1').get();
|
|
|
- const newCounter = lastTicket?.id ?? 0;
|
|
|
- db.query<null, number>('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}
|
|
|
+ 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<Ticket, number>(
|
|
|
+ '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<Ticket, []>(
|
|
|
+ "SELECT * FROM tickets ORDER BY created_at DESC LIMIT 1"
|
|
|
+ )
|
|
|
+ .get();
|
|
|
+ const newCounter = lastTicket?.id ?? 0;
|
|
|
+ db.query<null, number>(
|
|
|
+ '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}
|
|
|
+Чтобы ответить используйте \`/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(`Привет! Вам доступны следующие команды:
|
|
|
+`,
|
|
|
+ { 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();
|
|
|
+ /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();
|
|
|
+})();
|