add ci for tg bot
Deploy app frontend / app frontend (push) Failing after 54s
Deploy service couchdb / service couchdb (push) Successful in 38s
Deploy db-migrations / db-migrations (push) Failing after 50s
Deploy service message-delivery-method-web-push / service message-delivery-method-web-push (push) Failing after 1m27s
Deploy service tg-bot / service tg-bot (push) Failing after 44s

This commit is contained in:
2024-12-12 17:45:01 +02:00
parent e07d748e87
commit 7df04c0103
24 changed files with 309 additions and 365 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
FROM oven/bun:1.1.38-alpine
FROM node:22-alpine
COPY . ./
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=1 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8000/health || exit 1
CMD ["bun", "dist/server.js"]
CMD ["npm", "start"]
-25
View File
@@ -1,25 +0,0 @@
import esbuild from "esbuild";
import copyStaticFiles from "esbuild-copy-static-files";
esbuild
.build({
entryPoints: ["./src/bot/index.ts"], // Точка входа
outdir: "./dist", // Папка выхода
format: "esm", // Формат (ESM)
bundle: true, // Сборка в один файл
sourcemap: true, // Карты кода
platform: "node", // Платформа (Node.js)
target: ["node20"], // Целевая версия Node.js
external: ["canvas"], // Исключить модуль canvas
plugins: [
copyStaticFiles({
src: "./src/assets", // Папка с вашими статическими файлами
dest: "./dist/assets", // Куда копировать
}),
],
})
.then(() => console.log("Build complete!"))
.catch((err) => {
console.error("Build failed:", err);
process.exit(1);
});
+1 -3
View File
@@ -38,6 +38,7 @@
"start": "node --import=extensionless/register dist/bot/index.js"
},
"dependencies": {
"@grammyjs/conversations": "^1.2.0",
"@hereconnect/constants": "workspace:^",
"@hereconnect/couchdb-changes-stream": "workspace:^",
"@hereconnect/lib-service-health-checkserver": "workspace:^",
@@ -57,7 +58,6 @@
"svgo": "^3.3.2"
},
"devDependencies": {
"@grammyjs/conversations": "^1.2.0",
"@grammyjs/types": "^3.17.0",
"@hereconnect/types": "workspace:^",
"@types/bun": "^1.1.14",
@@ -68,8 +68,6 @@
"@types/pouchdb-upsert": "^2.2.9",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"esbuild": "^0.24.0",
"esbuild-copy-static-files": "^0.1.0",
"eslint": "^9.16.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
+1 -1
View File
@@ -50,7 +50,7 @@ interface CreateQRCodeOptions {
userUuid: string;
name: string;
placement: QRCodeDocument["placement"];
actions: string[];
actions: QRCodeDocument["actions"];
messageDeliveryMethod?: QRCodeDocument["messageDeliveryMethod"];
browser_uuid?: string;
telegram_id?: string;
+3 -3
View File
@@ -1,6 +1,6 @@
import { MyContext } from "@/types/myContext";
import { getQRCodesByUser } from "@/api/qrCodes";
import { buildManageKeyboard } from "@/bot/utils/keyboards";
import { MyContext } from "../../types/myContext";
import { getQRCodesByUser } from "../../api/qrCodes";
import { buildManageKeyboard } from "../../bot/utils/keyboards";
/**
* Обработка команды /manage с выводом подменю
@@ -1,10 +1,8 @@
import { Bot } from "grammy";
import { MyContext } from "../../types/myContext";
import { bot } from "old_bot"; // Ваш кастомный контекст
import { MyContext } from "../../types/myContext"; // Ваш кастомный контекст
/**
* Устанавливает команды для бота.
* @param bot - Экземпляр бота.
*/
export async function setupCommands(bot: Bot<MyContext>): Promise<void> {
// Настройка команд для английского языка (по умолчанию)
@@ -32,6 +30,4 @@ export async function setupCommands(bot: Bot<MyContext>): Promise<void> {
],
{ language_code: "ru" },
);
console.log("Команды успешно установлены.");
}
@@ -9,6 +9,7 @@ import {
} from "../utils/keyboards";
import { createQRCode } from "../../api/qrCodes";
import { generateQRCodeImage } from "../../services/qrGenerator";
import { selectMultipleActions } from "./selectMultipleActions";
// Тип разговора (конверсии)
type MyConversation = Conversation<MyContext>;
@@ -67,34 +68,25 @@ export async function newQRCodeConversation(
return;
}
// 3. Выбор действий, доступных при сканировании
await ctx.reply(
"Выберите одно или несколько действий, которые будут доступны при сканировании этого QR-кода:",
{
reply_markup: {
inline_keyboard: buildActionsKeyboard([
{ action: "sendMessage", label: "Отправить сообщение" },
{ action: "viewContactInfo", label: "Посмотреть контактные данные" },
]),
},
},
// 3. Выбор действий
const availableActions = [
{ action: "sendMessage", label: "📩 Отправить сообщение" },
{ action: "viewContactInfo", label: "📞 Посмотреть контактные данные" },
] as const;
const chosenActions = await selectMultipleActions(
conversation,
ctx,
availableActions,
);
const actionsMsg = await conversation.waitFor("callback_query:data");
const chosenAction = actionsMsg.update.callback_query?.data;
if (!chosenAction) {
await ctx.reply("Вы не выбрали действие. Попробуйте снова /new.");
if (chosenActions.length === 0) {
await ctx.reply("Вы не выбрали ни одного действия. Попробуйте снова /new.");
return;
}
// Подтверждаем выбор
await actionsMsg.answerCallbackQuery({ text: "Действие выбрано!" });
// Пока для MVP можно выбрать только одно действие, в будущем можно расширить
const chosenActions = [chosenAction];
// 4. Создание документа в БД
const { url, uri } = await createQRCode({
const { url } = await createQRCode({
userUuid,
name,
placement: typedPlacement,
@@ -0,0 +1,134 @@
import { MyContext } from "../../types/myContext";
import { Conversation } from "@grammyjs/conversations";
// Тип разговора
type MyConversation = Conversation<MyContext>;
interface Action {
readonly action: string;
readonly label: string;
}
/**
* Обработчик выбора нескольких действий
* @param conversation - Инстанс Conversation
* @param ctx - Контекст
* @param availableActions - Список доступных действий
* @returns Список выбранных действий
*/
export async function selectMultipleActions<T extends readonly Action[]>(
conversation: MyConversation,
ctx: MyContext,
availableActions: T,
): Promise<T[number]["action"][]> {
try {
if (!Array.isArray(availableActions) || availableActions.length === 0) {
throw new Error("Список доступных действий пуст или не определён.");
}
const selectedActions = new Set<T[number]["action"]>();
const updateKeyboard = () => {
return availableActions.map(({ action, label }) => [
{
text: `${selectedActions.has(action) ? "✔️ " : ""}${label}`,
callback_data: `action:${action}`,
},
]);
};
// Отправляем начальное сообщение
const replyMessage = await ctx.reply(
"Выберите одно или несколько действий, которые будут доступны при сканировании этого QR-кода. Нажмите 'Готово', когда закончите выбор:",
{
reply_markup: {
inline_keyboard: [
...updateKeyboard(),
[{ text: "Готово", callback_data: "action:done" }],
],
},
},
);
while (true) {
let actionChoice;
try {
actionChoice = await conversation.waitFor("callback_query:data");
} catch (err) {
console.error("Ошибка ожидания callback_query:", err);
await ctx.reply(
"Произошла ошибка при ожидании вашего выбора. Попробуйте снова.",
);
continue;
}
const choice = actionChoice.update.callback_query?.data;
if (!choice || !choice.startsWith("action:")) {
// Игнорируем callback_data, которые не относятся к текущему этапу
console.warn(`Игнорируем callback_data: ${choice}`);
await actionChoice.answerCallbackQuery({
text: "Неизвестное действие.",
});
continue;
}
const action = choice.replace("action:", "");
if (action === "done") {
await actionChoice.answerCallbackQuery({ text: "Выбор завершён!" });
break;
}
// Проверка: является ли выбор валидным
const validChoice = availableActions.find((a) => a.action === action);
if (!validChoice) {
console.error(`Недопустимое действие: ${action}`);
await actionChoice.answerCallbackQuery({
text: "Неверный выбор. Попробуйте снова.",
});
continue;
}
// Добавляем или убираем действие
if (selectedActions.has(action as T[number]["action"])) {
selectedActions.delete(action as T[number]["action"]);
} else {
selectedActions.add(action as T[number]["action"]);
}
await actionChoice.answerCallbackQuery({
text: `Вы ${
selectedActions.has(action as T[number]["action"])
? "добавили"
: "убрали"
} действие.`,
});
// Перерисовываем клавиатуру
try {
await actionChoice.editMessageReplyMarkup({
reply_markup: {
inline_keyboard: [
...updateKeyboard(),
[{ text: "Готово", callback_data: "action:done" }],
],
},
});
} catch (err) {
console.error("Ошибка обновления клавиатуры:", err);
await ctx.reply(
"Произошла ошибка при обновлении выбора. Попробуйте снова.",
);
}
}
return Array.from(selectedActions);
} catch (err) {
console.error("Общая ошибка в selectMultipleActions:", err);
await ctx.reply(
"Произошла непредвиденная ошибка. Попробуйте снова или обратитесь в поддержку.",
);
return [];
}
}
+18 -5
View File
@@ -1,10 +1,11 @@
import { Bot, session } from "grammy";
import { conversations, createConversation } from "@grammyjs/conversations";
import { MyContext } from "@/types/myContext";
import { env } from "@/config/env";
import { startHealthServer } from "@hereconnect/lib-service-health-checkserver";
import { MyContext } from "../types/myContext";
import { env } from "../config/env";
import { CouchDBStorageAdapter } from "./storageAdapters/CouchDBStorageAdapter";
import { loadUserMiddleware } from "./middleware/loadUser";
import { tgBotSessionsDb } from "@/dbs";
import { tgBotSessionsDb } from "../dbs";
import { setupCommands } from "./commands/setupCommands";
import { startCommand } from "./commands/start";
import { helpCommand } from "./commands/help";
@@ -13,12 +14,21 @@ import { manageCommand } from "./commands/manage";
import { callbackQueryHandler } from "./callbackHandlers";
import { newQRCodeConversation } from "./conversations/newQRCodeConversation";
const abortController = new AbortController();
const storage = new CouchDBStorageAdapter(tgBotSessionsDb, {
softDelete: true,
type: "tg_bot_session",
});
// Инициализируем бота с нужным типом контекста
const bot = new Bot<MyContext>(env.TELEGRAM_BOT_TOKEN);
const bot = new Bot<MyContext>(env.TELEGRAM_BOT_TOKEN, {
client: {
baseFetchConfig: {
signal: abortController.signal,
},
},
});
// Подключаем сессии. По необходимости определите initial state для сессии
bot.use(session({ storage, initial: () => ({}) }));
@@ -44,7 +54,10 @@ bot.on("callback_query:data", callbackQueryHandler);
await setupCommands(bot);
// Запуск бота
bot.start();
await bot.start();
await startHealthServer(abortController);
console.log("Бот запущен!");
})();
+5
View File
@@ -6,7 +6,12 @@ if (!process.env.READ_BASE_URL) {
throw new Error("Необходимо указать READ_BASE_URL в переменных окружения.");
}
if (!process.env.API_URL) {
throw new Error("Environment variable API_URL is not set");
}
export const env = {
TELEGRAM_BOT_TOKEN: process.env.BOT_TOKEN,
READ_BASE_URL: process.env.READ_BASE_URL,
API_URL: process.env.API_URL,
} as const;
+15 -17
View File
@@ -12,45 +12,46 @@ import type {
} from "@hereconnect/types";
import type { TelegramWebhookDesignDocument } from "@hereconnect/tg-bot-webhook-ddoc";
import { CouchDBSessionDocument } from "@/bot/storageAdapters/CouchDBStorageAdapter";
import type { SessionData } from "@/types/myContext";
import { CouchDBSessionDocument } from "./bot/storageAdapters/CouchDBStorageAdapter";
import type { SessionData } from "./types/myContext";
import { env } from "./config/env";
// import type { TgUpdateDocument } from "./types";
if (!process.env.API_URL) {
if (!env.API_URL) {
throw new Error("Environment variable API_URL is not set");
}
export const TG_BOT_DB_NAME = "tg_bot";
export const TG_BOT_DB_URL = `${process.env.API_URL}/${TG_BOT_DB_NAME}`;
export const TG_BOT_DB_URL = `${env.API_URL}/${TG_BOT_DB_NAME}`;
export const messagesDb = new PouchDB<MessageDocument>(
`${process.env.API_URL}/messages`,
`${env.API_URL}/messages`,
{
skip_setup: true,
},
);
export const serverSettingsDb = new PouchDB<MessageDeliveryMethodWebPushVapid>(
`${process.env.API_URL}/server_settings`,
`${env.API_URL}/server_settings`,
{
skip_setup: true,
},
);
export const publicSettingsDb = new PouchDB<PublicSettingsDocument>(
`${process.env.API_URL}/public_settings`,
`${env.API_URL}/public_settings`,
{
skip_setup: true,
},
);
export const qrDb = new PouchDB<QRCodeDocument>(`${process.env.API_URL}/qr`, {
export const qrDb = new PouchDB<QRCodeDocument>(`${env.API_URL}/qr`, {
skip_setup: true,
});
export const webPushSubscriptionsDb = new PouchDB<
WebPushSubscriptionDocument<PushSubscription>
>(`${process.env.API_URL}/web_push_subscriptions`, {
>(`${env.API_URL}/web_push_subscriptions`, {
skip_setup: true,
});
@@ -63,25 +64,22 @@ export const tgBotDesignDocumentsDb =
export const serviceSeqTrackerDb = tgBotDb as ServiceSeqTrackerDatabase;
export const tgBotSessionsDb = new PouchDB<CouchDBSessionDocument<SessionData>>(
`${process.env.API_URL}/tg_bot_sessions`,
`${env.API_URL}/tg_bot_sessions`,
{
skip_setup: true,
},
);
export const userChatSettingsDb = new PouchDB<UserChatSettingsDocument>(
`${process.env.API_URL}/user_chat_settings`,
`${env.API_URL}/user_chat_settings`,
{
skip_setup: true,
},
);
export const usersDb = new PouchDB<UserDocument>(
`${process.env.API_URL}/_users`,
{
skip_setup: true,
},
);
export const usersDb = new PouchDB<UserDocument>(`${env.API_URL}/_users`, {
skip_setup: true,
});
export const checkDatabases = async () => {
const entries = await Promise.all(
+1 -1
View File
@@ -1,5 +1,5 @@
import QRCodeStyling from "qr-code-styling";
import { SvgProcessor } from "@/utils/images/svg/SvgProcessor";
import { SvgProcessor } from "../utils/images/svg/SvgProcessor";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
+1 -4
View File
@@ -10,10 +10,7 @@
"skipLibCheck": true, // Пропуск проверки типов в библиотечных файлах
"declaration": true, // Генерация `.d.ts` файлов
"sourceMap": true, // Генерация `.map` файлов
"paths": {
"@/*": ["./src/*"] // Настройка алиасов (сопоставление путей)
}
},
"include": ["src"], // Указываем папку с исходниками
"exclude": ["node_modules", "dist"] // Исключаем ненужные директории
"exclude": ["node_modules", "dist", "old"] // Исключаем ненужные директории
}