Ryan Wright 4 månader sedan
incheckning
d44209d57e
9 ändrade filer med 452 tillägg och 0 borttagningar
  1. 2 0
      .dockerignore
  2. 177 0
      .gitignore
  3. 9 0
      Dockerfile
  4. 35 0
      README.md
  5. BIN
      bun.lockb
  6. 9 0
      docker-compose.yml
  7. 177 0
      index.ts
  8. 16 0
      package.json
  9. 27 0
      tsconfig.json

+ 2 - 0
.dockerignore

@@ -0,0 +1,2 @@
+node_modules
+.env

+ 177 - 0
.gitignore

@@ -0,0 +1,177 @@
+# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
+
+# Logs
+
+logs
+_.log
+npm-debug.log_
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Caches
+
+.cache
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# Runtime data
+
+pids
+_.pid
+_.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+
+lib-cov
+
+# Coverage directory used by tools like istanbul
+
+coverage
+*.lcov
+
+# nyc test coverage
+
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+
+bower_components
+
+# node-waf configuration
+
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+
+build/Release
+
+# Dependency directories
+
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+
+web_modules/
+
+# TypeScript cache
+
+*.tsbuildinfo
+
+# Optional npm cache directory
+
+.npm
+
+# Optional eslint cache
+
+.eslintcache
+
+# Optional stylelint cache
+
+.stylelintcache
+
+# Microbundle cache
+
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+
+.node_repl_history
+
+# Output of 'npm pack'
+
+*.tgz
+
+# Yarn Integrity file
+
+.yarn-integrity
+
+# dotenv environment variable files
+
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+
+.parcel-cache
+
+# Next.js build output
+
+.next
+out
+
+# Nuxt.js build / generate output
+
+.nuxt
+dist
+
+# Gatsby files
+
+# Comment in the public line in if your project uses Gatsby and not Next.js
+
+# https://nextjs.org/blog/next-9-1#public-directory-support
+
+# public
+
+# vuepress build output
+
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+
+.temp
+
+# Docusaurus cache and generated files
+
+.docusaurus
+
+# Serverless directories
+
+.serverless/
+
+# FuseBox cache
+
+.fusebox/
+
+# DynamoDB Local files
+
+.dynamodb/
+
+# TernJS port file
+
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+
+.vscode-test
+
+# yarn v2
+
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+# IntelliJ based IDEs
+.idea
+
+# Finder (MacOS) folder config
+.DS_Store
+
+db

+ 9 - 0
Dockerfile

@@ -0,0 +1,9 @@
+FROM oven/bun:1.1.18
+WORKDIR /usr/src/app
+
+COPY . .
+RUN bun i
+
+USER bun
+EXPOSE 3000/tcp
+ENTRYPOINT [ "bun", "run", "index.ts" ]

+ 35 - 0
README.md

@@ -0,0 +1,35 @@
+# Бот службы поддержки
+
+Как работает бот. Есть сам бот, и есть приватная группа. В бот пишет клиент. В группе сидят сотрудники поддержки. Когда клиент пишет в бот сообщение, ему выдается номер и оно передается в группу. Сотрудники могут ответить на обращение, решить проблему и закрыть тикет. Раз в сутки запускается очистка базы от закрытых тикетов. В качестве идентификатора используется порядковый номер строки в базе на момент добавления, поэтому коды тикетов получаются короткими и удобными.
+
+## Как запустить
+
+0. Вам нужен `docker compose` на сервере. Установите его подходящим для вашей системы способом и следуйте дальнейшей инструкции.
+1. Создайте в папке с приложением пустую папку с именем `db` и пустой файл с именем .env
+2. Запишите в файл `.env` следующие строки и не забудьте сохранить изменения
+
+```bash
+TELEGRAM_SUPPORT_CHAT_ID=-1234567 # ID приватного чата, где сидят сотрудники
+TELEGRAM_TOKEN=00000000:AaaaaaAaaaaaaaAa-bBBBBbbbbb # ID бота из BotFather
+APP_NAME=Моё приложение # название вашего приложения
+```
+
+3. Выполните в консоли сервера следующую команду
+
+```bash
+docker compose up -d --build
+```
+
+4. Все готово, можете закрывать консоль
+
+## Команды
+
+Клиент в чате с ботом может писать только одну команды `/start`. Все остальные команды предназначены для приватного чата.
+
+В чате сотрудники могут вызывать следующие команды
+
+- /help - показать подсказку по командам
+- /tickets - вывести список 10 самых новых открытых тикетов. Если открытых тикетов больше - будет показан знак `...`
+- /closed - вывести количество закрытых тикетов. Эти тикеты будут удалены через какое-то время
+- /answer - ответить на тикет. Сообщение будет переслано клиенту
+- /close - закрыть тикет. Он будет помечен для удаления, а клиенту придет благодарность за обращение

BIN
bun.lockb


+ 9 - 0
docker-compose.yml

@@ -0,0 +1,9 @@
+version: "3"
+services:
+  bot:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    env_file: ".env"
+    volumes:
+      - "./db:/usr/src/app/db"

+ 177 - 0
index.ts

@@ -0,0 +1,177 @@
+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<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 (
+    ?,
+    ?, 
+    ?, 
+    '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}
+Время регистрации ${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();

+ 16 - 0
package.json

@@ -0,0 +1,16 @@
+{
+  "name": "telegram-support-bot",
+  "description": "Bot for service desk",
+  "version": "0.1.0",
+  "module": "index.ts",
+  "devDependencies": {
+    "@types/bun": "latest"
+  },
+  "peerDependencies": {
+    "typescript": "^5.0.0"
+  },
+  "type": "module",
+  "dependencies": {
+    "grammy": "^1.27.0"
+  }
+}

+ 27 - 0
tsconfig.json

@@ -0,0 +1,27 @@
+{
+  "compilerOptions": {
+    // Enable latest features
+    "lib": ["ESNext", "DOM"],
+    "target": "ESNext",
+    "module": "ESNext",
+    "moduleDetection": "force",
+    "jsx": "react-jsx",
+    "allowJs": true,
+
+    // Bundler mode
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "verbatimModuleSyntax": true,
+    "noEmit": true,
+
+    // Best practices
+    "strict": true,
+    "skipLibCheck": true,
+    "noFallthroughCasesInSwitch": true,
+
+    // Some stricter flags (disabled by default)
+    "noUnusedLocals": false,
+    "noUnusedParameters": false,
+    "noPropertyAccessFromIndexSignature": false
+  }
+}