send messages to telegram from RQCP (ReadQrcodeChatPage) with link to MQCP (ManageQrcodeChatPage)
Deploy app frontend / app frontend (push) Successful in 2m43s
Deploy db-migrations / db-migrations (push) Successful in 1m8s
Deploy service message-delivery-method-web-push / service message-delivery-method-web-push (push) Successful in 1m30s
Deploy service tg-bot / service tg-bot (push) Failing after 1m54s

This commit is contained in:
2024-12-14 23:58:29 +02:00
parent 90fe585ff8
commit d71b405277
22 changed files with 396 additions and 127 deletions
@@ -21,6 +21,8 @@ jobs:
COUCHDB_PASSWORD: ${{ secrets.COUCHDB_PASSWORD }} COUCHDB_PASSWORD: ${{ secrets.COUCHDB_PASSWORD }}
BOT_TOKEN: ${{ secrets.BOT_TOKEN }} BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
READ_BASE_URL: ${{ vars.READ_BASE_URL }} READ_BASE_URL: ${{ vars.READ_BASE_URL }}
APP_BASE_URL: ${{ vars.APP_BASE_URL }}
HEARTBEAT_INTERVAL: ${{ vars.HEARTBEAT_INTERVAL }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -3,6 +3,7 @@
<div <div
v-for="message in messages" v-for="message in messages"
:key="message._id" :key="message._id"
:id="`message-${message.created_at}`"
class="mb-4 max-w-md" class="mb-4 max-w-md"
:class="{ :class="{
'ml-auto text-right': message.from === userRole, 'ml-auto text-right': message.from === userRole,
@@ -6,7 +6,7 @@ import http from "http";
* @param defaultPort - Порт для сервера по умолчанию (если не указан в ENV HEALTH_PORT). * @param defaultPort - Порт для сервера по умолчанию (если не указан в ENV HEALTH_PORT).
* @param defaultHostName - Имя хоста для сервера по умолчанию (если не указан в ENV HEALTH_HOST). * @param defaultHostName - Имя хоста для сервера по умолчанию (если не указан в ENV HEALTH_HOST).
*/ */
export function startHealthServer( export function createHealthServer(
controller: AbortController, controller: AbortController,
defaultPort: number = 8000, defaultPort: number = 8000,
defaultHostName: string = "127.0.0.1", defaultHostName: string = "127.0.0.1",
@@ -43,20 +43,45 @@ export function startHealthServer(
} }
}); });
return new Promise<void>((resolve, reject) => { server?.unref?.();
server.once("error", reject);
server.listen(port, hostname, () => {
resolve();
console.log(
`Healthcheck server running on http://${hostname}:${port}/health`,
);
});
// Закрываем сервер при активации AbortController const stop = () => {
controller.signal.addEventListener("abort", () => { return new Promise<void>((resolve, reject) => {
console.log("Healthcheck server shutting down due to abort signal."); server.close((error) => {
reject(); if (error) reject(error);
server.close(); else resolve();
});
}); });
}); };
const start = () => {
return new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, hostname, () => {
resolve();
console.log(
`Healthcheck server running on http://${hostname}:${port}/health`,
);
});
// Закрываем сервер при активации AbortController
controller.signal.addEventListener("abort", () => {
console.log("Healthcheck server shutting down due to abort signal.");
stop().then(reject, reject);
});
});
};
return {
start,
stop,
};
} }
export const startHealthServer = (
controller: AbortController,
defaultPort: number = 8000,
defaultHostName: string = "127.0.0.1",
) => {
return createHealthServer(controller, defaultPort, defaultHostName).start();
};
@@ -24,7 +24,12 @@ export interface UserChatSettingsDocument extends BaseDocument {
browser_uuid: string; browser_uuid: string;
/** Включены ли push-уведомления */ /** Включены ли push-уведомления */
is_push_notification_enabled: boolean; is_push_notification_enabled?: boolean;
/**
* Включены ли сообщения в Telegram
*/
is_telegram_messages_enabled?: boolean;
/** Дата последнего обновления */ /** Дата последнего обновления */
updated_at?: string; updated_at?: string;
+22
View File
@@ -337,6 +337,9 @@ importers:
'@grammyjs/conversations': '@grammyjs/conversations':
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0(grammy@1.33.0) version: 1.2.0(grammy@1.33.0)
'@grammyjs/parse-mode':
specifier: ^1.10.0
version: 1.10.0(grammy@1.33.0)
'@hereconnect/constants': '@hereconnect/constants':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/constants version: link:../../packages/constants
@@ -425,6 +428,9 @@ importers:
tsx: tsx:
specifier: ^4.19.2 specifier: ^4.19.2
version: 4.19.2 version: 4.19.2
type-fest:
specifier: ^4.30.1
version: 4.30.1
typescript: typescript:
specifier: ^5.7.2 specifier: ^5.7.2
version: 5.7.2 version: 5.7.2
@@ -921,6 +927,12 @@ packages:
peerDependencies: peerDependencies:
grammy: ^1.20.1 grammy: ^1.20.1
'@grammyjs/parse-mode@1.10.0':
resolution: {integrity: sha512-ZjbY2Ax0b4Nf8lPz3NV0cWDxUC10kOkzgxws+iTdgG+hAiPQVUnP/oJnAKrW1ZQOWULZYQ4GOR/+aCZHyUuLQA==}
engines: {node: '>=14.13.1'}
peerDependencies:
grammy: ^1.20.1
'@grammyjs/types@3.17.0': '@grammyjs/types@3.17.0':
resolution: {integrity: sha512-e8AR3xQwRAFX248E7Qw/7mIu1OzvoXloJzOBJVtuPKzzL7tGkn5trZAdZUBgGViVQg5ZwVS/x9N2nRrcyH/DfA==} resolution: {integrity: sha512-e8AR3xQwRAFX248E7Qw/7mIu1OzvoXloJzOBJVtuPKzzL7tGkn5trZAdZUBgGViVQg5ZwVS/x9N2nRrcyH/DfA==}
@@ -3637,6 +3649,10 @@ packages:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
type-fest@4.30.1:
resolution: {integrity: sha512-ojFL7eDMX2NF0xMbDwPZJ8sb7ckqtlAi1GsmgsFXvErT9kFTk1r0DuQKvrCh73M6D4nngeHJmvogF9OluXs7Hw==}
engines: {node: '>=16'}
typescript-eslint@8.16.0: typescript-eslint@8.16.0:
resolution: {integrity: sha512-wDkVmlY6O2do4V+lZd0GtRfbtXbeD0q9WygwXXSJnC1xorE8eqyC2L1tJimqpSeFrOzRlYtWnUp/uzgHQOgfBQ==} resolution: {integrity: sha512-wDkVmlY6O2do4V+lZd0GtRfbtXbeD0q9WygwXXSJnC1xorE8eqyC2L1tJimqpSeFrOzRlYtWnUp/uzgHQOgfBQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4426,6 +4442,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@grammyjs/parse-mode@1.10.0(grammy@1.33.0)':
dependencies:
grammy: 1.33.0
'@grammyjs/types@3.17.0': {} '@grammyjs/types@3.17.0': {}
'@humanfs/core@0.19.1': {} '@humanfs/core@0.19.1': {}
@@ -7380,6 +7400,8 @@ snapshots:
type-fest@0.20.2: {} type-fest@0.20.2: {}
type-fest@4.30.1: {}
typescript-eslint@8.16.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3): typescript-eslint@8.16.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.16.0(@typescript-eslint/parser@8.16.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3) '@typescript-eslint/eslint-plugin': 8.16.0(@typescript-eslint/parser@8.16.0(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.15.0(jiti@1.21.6))(typescript@5.6.3)
@@ -10,11 +10,6 @@
"node": ">=v20.10.0", "node": ">=v20.10.0",
"pnpm": ">=8.15.0" "pnpm": ">=8.15.0"
}, },
"homepage": "",
"repository": {
"type": "git",
"url": ""
},
"files": [ "files": [
"dist/**/*" "dist/**/*"
], ],
@@ -62,7 +62,7 @@ export const userChatSettingsDb = new PouchDB<UserChatSettingsDocument>(
}, },
); );
export const checkDatabases = async () => { const checkDatabases = async () => {
const entries = await Promise.all( const entries = await Promise.all(
Object.entries({ Object.entries({
serverSettingsDb, serverSettingsDb,
@@ -92,3 +92,5 @@ export const checkDatabases = async () => {
return Object.fromEntries(entries); return Object.fromEntries(entries);
}; };
export const checkDatabasesPromise = checkDatabases();
@@ -9,7 +9,7 @@ import {
messagesDbUrl, messagesDbUrl,
webPushSubscriptionsDb, webPushSubscriptionsDb,
userChatSettingsDb, userChatSettingsDb,
checkDatabases, checkDatabasesPromise,
serviceSeqTrackerDb, serviceSeqTrackerDb,
} from "./dbs"; } from "./dbs";
import { setupVapidDetails } from "./setupVapidDetails"; import { setupVapidDetails } from "./setupVapidDetails";
@@ -29,7 +29,7 @@ function getTopicSafe(str: string) {
} }
const start = async () => { const start = async () => {
const checkedDatabases = await checkDatabases(); const checkedDatabases = await checkDatabasesPromise;
await setupVapidDetails(); await setupVapidDetails();
const serviceSeqTracker = await ServiceSeqTracker.Init( const serviceSeqTracker = await ServiceSeqTracker.Init(
@@ -47,10 +47,7 @@ const start = async () => {
heartbeat, heartbeat,
filter: "_selector", filter: "_selector",
selector: { selector: {
$or: [ type: "message",
{ type: "message" },
{ type: "service", service: "message-delivery-method-web-push" },
],
}, },
}, },
); );
@@ -76,14 +73,14 @@ const start = async () => {
for await (const change of changesStream) { for await (const change of changesStream) {
const { seq, doc: msgDoc } = change; const { seq, doc: msgDoc } = change;
if (!msgDoc) {
console.error("Change missing document:", change);
continue;
}
serviceSeqTracker.checkNextSeq(seq);
try { try {
if (!msgDoc) {
console.error("Change missing document:", change);
continue;
}
serviceSeqTracker.checkNextSeq(seq);
const qrDoc = await qrDb.get(`qr_code:${msgDoc.qr_code_uri}`); const qrDoc = await qrDb.get(`qr_code:${msgDoc.qr_code_uri}`);
const timestamp = new Date(msgDoc.created_at).getTime(); const timestamp = new Date(msgDoc.created_at).getTime();
@@ -264,7 +261,7 @@ const start = async () => {
); );
} catch (error) { } catch (error) {
console.error( console.error(
`Failed processing: ${JSON.stringify(error)} change: ${JSON.stringify({ id: msgDoc._id, rev: msgDoc._rev, seq: seq })}`, `Failed processing: ${JSON.stringify(error)} change: ${JSON.stringify({ id: msgDoc?._id, rev: msgDoc?._rev, seq: seq })}`,
); );
} finally { } finally {
await serviceSeqTracker.saveSeq(seq); await serviceSeqTracker.saveSeq(seq);
@@ -11,9 +11,6 @@
"declaration": true, // Генерация `.d.ts` файлов "declaration": true, // Генерация `.d.ts` файлов
"sourceMap": true, // Генерация `.map` файлов "sourceMap": true, // Генерация `.map` файлов
"baseUrl": "./", // Базовая директория для путей "baseUrl": "./", // Базовая директория для путей
"paths": {
"*": ["src/*"] // Настройка алиасов (сопоставление путей)
}
}, },
"include": ["src"], // Указываем папку с исходниками "include": ["src"], // Указываем папку с исходниками
"exclude": ["node_modules", "dist"] // Исключаем ненужные директории "exclude": ["node_modules", "dist"] // Исключаем ненужные директории
+6 -4
View File
@@ -2,7 +2,7 @@
"name": "@hereconnect/service-tg-bot", "name": "@hereconnect/service-tg-bot",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "dist/server.ts", "main": "dist/service.js",
"type": "module", "type": "module",
"license": "@hereconnect/license", "license": "@hereconnect/license",
"private": true, "private": true,
@@ -29,17 +29,18 @@
"contributors": [], "contributors": [],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"dev": "tsx src/bot/index.ts", "dev": "tsx src/service.ts",
"dev:watch": "tsx --watch src/bot/index.ts", "dev:watch": "tsx --watch src/service.ts",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"lint": "eslint src", "lint": "eslint src",
"lint:fix": "eslint src --fix", "lint:fix": "eslint src --fix",
"validate": "npm run type-check && npm run lint", "validate": "npm run type-check && npm run lint",
"start": "node --import=extensionless/register dist/bot/index.js" "start": "node --import=extensionless/register dist/service.js"
}, },
"dependencies": { "dependencies": {
"@grammyjs/conversations": "^1.2.0", "@grammyjs/conversations": "^1.2.0",
"@grammyjs/parse-mode": "^1.10.0",
"@hereconnect/constants": "workspace:^", "@hereconnect/constants": "workspace:^",
"@hereconnect/couchdb-changes-stream": "workspace:^", "@hereconnect/couchdb-changes-stream": "workspace:^",
"@hereconnect/lib-service-health-checkserver": "workspace:^", "@hereconnect/lib-service-health-checkserver": "workspace:^",
@@ -71,6 +72,7 @@
"@typescript-eslint/parser": "^8.18.0", "@typescript-eslint/parser": "^8.18.0",
"eslint": "^9.16.0", "eslint": "^9.16.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"type-fest": "^4.30.1",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }
@@ -2,7 +2,7 @@ import { type QRCodeDocument } from "@hereconnect/types";
import { Conversation } from "@grammyjs/conversations"; import { Conversation } from "@grammyjs/conversations";
import { InputFile } from "grammy"; import { InputFile } from "grammy";
import type { MyContext } from "../../types/myContext"; import type { MyContext } from "../../types/myContext";
import { qrCodePlacements } from "../../models/types"; import { qrCodePlacements } from "../../types/models";
import { import {
buildPlacementKeyboard, buildPlacementKeyboard,
buildActionsKeyboard, buildActionsKeyboard,
@@ -11,11 +11,8 @@ import { createQRCode } from "../../api/qrCodes";
import { generateQRCodeImage } from "../../services/qrGenerator"; import { generateQRCodeImage } from "../../services/qrGenerator";
import { selectMultipleActions } from "./selectMultipleActions"; import { selectMultipleActions } from "./selectMultipleActions";
// Тип разговора (конверсии)
type MyConversation = Conversation<MyContext>;
export async function newQRCodeConversation( export async function newQRCodeConversation(
conversation: MyConversation, conversation: Conversation<MyContext>,
ctx: MyContext, ctx: MyContext,
) { ) {
if (!ctx.userDoc) { if (!ctx.userDoc) {
+35 -40
View File
@@ -1,64 +1,59 @@
import { Bot, session } from "grammy"; import { Bot, session } from "grammy";
import { conversations, createConversation } from "@grammyjs/conversations"; import { conversations, createConversation } from "@grammyjs/conversations";
import { startHealthServer } from "@hereconnect/lib-service-health-checkserver";
import { MyContext } from "../types/myContext"; import { MyContext } from "../types/myContext";
import { env } from "../config/env"; import { env } from "../config/env";
import { CouchDBStorageAdapter } from "./storageAdapters/CouchDBStorageAdapter"; import { CouchDBStorageAdapter } from "./storageAdapters/CouchDBStorageAdapter";
import { loadUserMiddleware } from "./middleware/loadUser"; import { loadUserMiddleware } from "./middleware/loadUser";
import { tgBotSessionsDb } from "../dbs"; import { tgBotSessionsDb } from "../dbs";
import { setupCommands } from "./commands/setupCommands";
import { startCommand } from "./commands/start"; import { startCommand } from "./commands/start";
import { helpCommand } from "./commands/help"; import { helpCommand } from "./commands/help";
import { newCommand } from "./commands/new"; import { newCommand } from "./commands/new";
import { manageCommand } from "./commands/manage"; import { manageCommand } from "./commands/manage";
import { callbackQueryHandler } from "./callbackHandlers"; import { callbackQueryHandler } from "./callbackHandlers";
import { newQRCodeConversation } from "./conversations/newQRCodeConversation"; import { newQRCodeConversation } from "./conversations/newQRCodeConversation";
import { hydrateReply } from "@grammyjs/parse-mode";
const abortController = new AbortController(); export { setupCommands } from "./commands/setupCommands";
const storage = new CouchDBStorageAdapter(tgBotSessionsDb, { export const createBot = ({
softDelete: true, abortController,
type: "tg_bot_session", }: {
}); abortController: 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: { client: {
baseFetchConfig: { baseFetchConfig: {
signal: abortController.signal, signal: abortController.signal,
},
}, },
}, });
});
// Подключаем сессии. По необходимости определите initial state для сессии bot.use(hydrateReply);
bot.use(session({ storage, initial: () => ({}) }));
// Подключаем conversations для многошаговых сценариев // Подключаем сессии. По необходимости определите initial state для сессии
bot.use(conversations()); bot.use(session({ storage, initial: () => ({}) }));
bot.use(createConversation(newQRCodeConversation, "newQRCodeConversation"));
// Middleware для загрузки или создания пользователя // Подключаем conversations для многошаговых сценариев
bot.use(loadUserMiddleware); bot.use(conversations());
bot.use(createConversation(newQRCodeConversation));
// Регистрация команд // Middleware для загрузки или создания пользователя
bot.command("start", startCommand); bot.use(loadUserMiddleware);
bot.command("help", helpCommand);
bot.command("new", newCommand);
bot.command("manage", manageCommand);
// Обработка callback_query // Регистрация команд
bot.on("callback_query:data", callbackQueryHandler); bot.command("start", startCommand);
bot.command("help", helpCommand);
bot.command("new", newCommand);
bot.command("manage", manageCommand);
(async () => { // Обработка callback_query
// Установка команд bot.on("callback_query:data", callbackQueryHandler);
await setupCommands(bot);
// Запуск бота return bot;
bot.start(); };
await startHealthServer(abortController);
console.log("Бот запущен!");
})();
export { bot };
+6
View File
@@ -10,8 +10,14 @@ if (!process.env.API_URL) {
throw new Error("Environment variable API_URL is not set"); throw new Error("Environment variable API_URL is not set");
} }
if (!process.env.HEARTBEAT_INTERVAL) {
throw new Error("Environment variable HEARTBEAT_INTERVAL is not set");
}
export const env = { export const env = {
TELEGRAM_BOT_TOKEN: process.env.BOT_TOKEN, TELEGRAM_BOT_TOKEN: process.env.BOT_TOKEN,
READ_BASE_URL: process.env.READ_BASE_URL, READ_BASE_URL: process.env.READ_BASE_URL,
APP_BASE_URL: process.env.APP_BASE_URL,
API_URL: process.env.API_URL, API_URL: process.env.API_URL,
HEARTBEAT_INTERVAL: parseInt(process.env.HEARTBEAT_INTERVAL, 10),
} as const; } as const;
+29 -30
View File
@@ -2,7 +2,6 @@ import PouchDB from "./PouchDB";
import { ServiceSeqTrackerDatabase } from "@hereconnect/lib-service-seq-tracker"; import { ServiceSeqTrackerDatabase } from "@hereconnect/lib-service-seq-tracker";
import type { import type {
MessageDocument,
QRCodeDocument, QRCodeDocument,
MessageDeliveryMethodWebPushVapid, MessageDeliveryMethodWebPushVapid,
UserChatSettingsDocument, UserChatSettingsDocument,
@@ -11,25 +10,26 @@ import type {
UserDocument, UserDocument,
} from "@hereconnect/types"; } from "@hereconnect/types";
import type { ServiceSeqTrackerDocument } from "@hereconnect/lib-service-seq-tracker";
import type { TelegramWebhookDesignDocument } from "@hereconnect/tg-bot-webhook-ddoc"; import type { TelegramWebhookDesignDocument } from "@hereconnect/tg-bot-webhook-ddoc";
import { CouchDBSessionDocument } from "./bot/storageAdapters/CouchDBStorageAdapter"; import { CouchDBSessionDocument } from "./bot/storageAdapters/CouchDBStorageAdapter";
import type { SessionData } from "./types/myContext"; import type { SessionData } from "./types/myContext";
import { env } from "./config/env"; import { env } from "./config/env";
// import type { TgUpdateDocument } from "./types";
if (!env.API_URL) { if (!env.API_URL) {
throw new Error("Environment variable API_URL is not set"); throw new Error("Environment variable API_URL is not set");
} }
export const TG_BOT_DB_NAME = "tg_bot"; export const TG_BOT_DB_NAME = "tg_bot";
export const MESSAGES_DB_NAME = "messages";
export const TG_BOT_DB_URL = `${env.API_URL}/${TG_BOT_DB_NAME}`; export const TG_BOT_DB_URL = `${env.API_URL}/${TG_BOT_DB_NAME}`;
export const MESSAGES_DB_URL = `${env.API_URL}/${MESSAGES_DB_NAME}`;
export const messagesDb = new PouchDB<MessageDocument>( export const messagesServiceSeqTrackerDb =
`${env.API_URL}/messages`, new PouchDB<ServiceSeqTrackerDocument>(MESSAGES_DB_URL, {
{
skip_setup: true, skip_setup: true,
}, });
);
export const serverSettingsDb = new PouchDB<MessageDeliveryMethodWebPushVapid>( export const serverSettingsDb = new PouchDB<MessageDeliveryMethodWebPushVapid>(
`${env.API_URL}/server_settings`, `${env.API_URL}/server_settings`,
@@ -49,18 +49,10 @@ export const qrDb = new PouchDB<QRCodeDocument>(`${env.API_URL}/qr`, {
skip_setup: true, skip_setup: true,
}); });
export const webPushSubscriptionsDb = new PouchDB<
WebPushSubscriptionDocument<PushSubscription>
>(`${env.API_URL}/web_push_subscriptions`, {
skip_setup: true,
});
export const tgBotDb = new PouchDB(TG_BOT_DB_URL, { export const tgBotDb = new PouchDB(TG_BOT_DB_URL, {
skip_setup: true, skip_setup: true,
}); });
export const tgBotDesignDocumentsDb =
tgBotDb as PouchDB.Database<TelegramWebhookDesignDocument>;
export const serviceSeqTrackerDb = tgBotDb as ServiceSeqTrackerDatabase; export const serviceSeqTrackerDb = tgBotDb as ServiceSeqTrackerDatabase;
export const tgBotSessionsDb = new PouchDB<CouchDBSessionDocument<SessionData>>( export const tgBotSessionsDb = new PouchDB<CouchDBSessionDocument<SessionData>>(
@@ -81,20 +73,25 @@ export const usersDb = new PouchDB<UserDocument>(`${env.API_URL}/_users`, {
skip_setup: true, skip_setup: true,
}); });
export const checkDatabases = async () => { const checkDatabases = async () => {
const entries = await Promise.all( const databases = {
Object.entries({ serverSettingsDb,
serverSettingsDb, publicSettingsDb,
publicSettingsDb, qrDb,
qrDb, usersDb,
webPushSubscriptionsDb, serviceSeqTrackerDb,
serviceSeqTrackerDb, userChatSettingsDb,
userChatSettingsDb, tgBotDb,
tgBotDb, tgBotSessionsDb,
tgBotSessionsDb, messagesServiceSeqTrackerDb,
} as const).map( } as const;
const databasesEntries = Object.entries(databases);
const resultEntries = await Promise.all(
databasesEntries.map(
async ([name, db]): Promise< async ([name, db]): Promise<
[string, Awaited<ReturnType<typeof db.info>>] [typeof name, Awaited<ReturnType<typeof db.info>>]
> => { > => {
const info = await db.info(); const info = await db.info();
return [name, info]; return [name, info];
@@ -102,7 +99,7 @@ export const checkDatabases = async () => {
), ),
); );
for (const [name, info] of entries) { for (const [name, info] of resultEntries) {
if ("error" in info && "reason" in info) { if ("error" in info && "reason" in info) {
console.error(info); console.error(info);
throw new Error(`[${name}] ${info.error}: ${info.reason}`); throw new Error(`[${name}] ${info.error}: ${info.reason}`);
@@ -112,5 +109,7 @@ export const checkDatabases = async () => {
} }
} }
return Object.fromEntries(entries); return Object.fromEntries(resultEntries);
}; };
export const checkDatabasesPromise = checkDatabases();
+8
View File
@@ -0,0 +1,8 @@
import type { Entries, Entry } from "type-fest";
declare global {
interface ObjectConstructor {
entries<T extends object>(o: T): Entries<T>;
fromEntries<T>(entries: Entry<T>[]): T;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { createHealthServer } from "@hereconnect/lib-service-health-checkserver";
import { createBot, setupCommands } from "./bot";
import { createServiceChangesMessagesDb } from "./services/serviceChangesMessagesDb";
import { iterateMessagesChanges } from "./services/iterateMessagesChanges";
const abortController = new AbortController();
(async () => {
const healthServer = createHealthServer(abortController);
const bot = createBot({ abortController });
const { changesStream, serviceSeqTracker } =
await createServiceChangesMessagesDb({
abortController,
});
process.on("SIGINT", async () => {
changesStream.stop();
bot.stop();
});
await healthServer.start();
await bot.init();
// Устанавливаем команд
await setupCommands(bot);
bot.start();
console.log("Бот запущен!");
iterateMessagesChanges({ changesStream, serviceSeqTracker, bot });
})();
@@ -0,0 +1,111 @@
import type { MyContext } from "../types/myContext";
import { Bot, Context, Keyboard } from "grammy";
import type { ServiceSeqTracker } from "@hereconnect/lib-service-seq-tracker";
import type { CouchDBChangesStream } from "@hereconnect/couchdb-changes-stream";
import type { MessageDocument, UserDocument } from "@hereconnect/types";
import { qrDb, userChatSettingsDb, usersDb } from "../dbs";
// import { blockquote, fmt, italic, link } from "@grammyjs/parse-mode";
import { env } from "../config/env";
import { escapeHtml } from "../utils/escapeHtml";
async function handleChange(msgDoc: MessageDocument, bot: Bot<MyContext>) {
const qrDoc = await qrDb.get(`qr_code:${msgDoc.qr_code_uri}`);
if (qrDoc.messageDeliveryMethod !== "telegram") {
console.log(`Skip !telegram`);
return;
}
const fields = ["telegram_id"] as const;
const [{ docs: telegramUsersDocs }, { rows: userChatSettingsRows }] =
await Promise.all([
usersDb.find({
selector: {
user_uuid: qrDoc.user_uuid,
telegram_id: {
$exists: true,
$ne: null,
},
},
fields: [...fields],
}) as Promise<{
docs: Required<Pick<UserDocument, (typeof fields)[number]>>[];
}>,
userChatSettingsDb.allDocs({
startkey: `user_chat_settings:${msgDoc.qr_code_uri}:${msgDoc.chat}:owner:`,
endkey: `user_chat_settings:${msgDoc.qr_code_uri}:${msgDoc.chat}:owner:\uffff`,
include_docs: true,
}),
]);
if (
userChatSettingsRows.some(
(row) => row.doc?.is_telegram_messages_enabled === false,
)
) {
console.log("telegram notifications is disabled");
return;
}
if (telegramUsersDocs.length === 0) {
console.log("NO TELEGRAM USERS");
} else {
console.log(`sending to ${telegramUsersDocs.length} telegram accounts`);
}
const hashTag = `#qr_${qrDoc.uri}_c${msgDoc.chat}`;
const manageMessageUrl = `${env.APP_BASE_URL}/manage/qr/${qrDoc.uri}/chat/${msgDoc.chat}#message-${encodeURIComponent(msgDoc.created_at)}`;
// const manageMessageUrl = `tg://resolve?domain=${bot.botInfo.username}&start=message:${qrDoc.uri}:${msgDoc.created_at}`;
// const msgCaption = ` — ${link("сообщение", manageMessageUrl)} QR-кода «${qrDoc.name}»`;
// const caption = fmt`${link("сообщение", manageMessageUrl)} QR-кода «${qrDoc.name}»`;
// const body = fmt`${msgDoc.body.trim()} — ${italic(caption)}`;
const body = {
text: `${escapeHtml(msgDoc.body.trim())} — <i><a href="${escapeHtml(manageMessageUrl)}">сообщение</a> ${escapeHtml(hashTag)} QR-кода <b>«${escapeHtml(qrDoc.name)}»</b></i>`,
entities: undefined,
};
for (const telegramUserDoc of telegramUsersDocs) {
await bot.api.sendMessage(telegramUserDoc.telegram_id, body.text, {
parse_mode: "HTML",
// parse_mode: "MarkdownV2",
link_preview_options: { is_disabled: true },
entities: body.entities,
reply_markup: { force_reply: true },
});
}
}
export const iterateMessagesChanges = async ({
changesStream,
serviceSeqTracker,
bot,
}: {
changesStream: CouchDBChangesStream<MessageDocument>;
serviceSeqTracker: ServiceSeqTracker;
bot: Bot<MyContext>;
}) => {
for await (const change of changesStream) {
const { id, seq, doc: msgDoc, changes } = change;
console.log("seq", seq);
try {
if (!msgDoc) {
console.error("Change missing document:", change);
continue;
}
serviceSeqTracker.checkNextSeq(seq);
await handleChange(msgDoc, bot);
} catch (error) {
console.error(
`Failed processing: ${JSON.stringify(error)} change: ${JSON.stringify({ id, rev: changes.map((c) => c.rev).join(","), seq: seq })}`,
);
} finally {
await serviceSeqTracker.saveSeq(seq);
}
}
};
@@ -0,0 +1,52 @@
import { ServiceSeqTracker } from "@hereconnect/lib-service-seq-tracker";
import { CouchDBChangesStream } from "@hereconnect/couchdb-changes-stream";
import type { MessageDocument } from "@hereconnect/types";
import {
MESSAGES_DB_URL,
messagesServiceSeqTrackerDb,
checkDatabasesPromise,
} from "../dbs";
import { env } from "../config/env";
export const createServiceChangesMessagesDb = async ({
abortController,
}: {
abortController: AbortController;
}) => {
const {
messagesServiceSeqTrackerDb: { update_seq: lastSeq },
} = await checkDatabasesPromise;
const serviceSeqTracker = await ServiceSeqTracker.Init(
"_local/service:tg-bot",
messagesServiceSeqTrackerDb,
);
const changesStream = new CouchDBChangesStream<MessageDocument>(
MESSAGES_DB_URL,
{
abortController,
since: serviceSeqTracker.lastSeq,
feed: "continuous",
include_docs: true,
heartbeat: env.HEARTBEAT_INTERVAL,
filter: "_selector",
selector: {
type: "message",
},
},
);
console.info("Service started from sequence", serviceSeqTracker.lastSeq);
if (
parseInt(serviceSeqTracker.lastSeq.toString(), 10) ===
parseInt(lastSeq.toString(), 10)
) {
console.log("Since is the last seq");
} else {
console.log("Last seq is", lastSeq);
}
return { changesStream, serviceSeqTracker };
};
+8 -7
View File
@@ -1,7 +1,8 @@
// src/types/myContext.ts // src/types/myContext.ts
import { Context, SessionFlavor } from "grammy"; import type { Context, SessionFlavor } from "grammy";
import { ConversationFlavor } from "@grammyjs/conversations"; import type { ConversationFlavor, Conversation } from "@grammyjs/conversations";
import type { ParseModeFlavor } from "@grammyjs/parse-mode";
import type { UserDocument } from "@hereconnect/types"; import type { UserDocument } from "@hereconnect/types";
export interface SessionData { export interface SessionData {
@@ -9,11 +10,11 @@ export interface SessionData {
} }
// Базовый контекст без conversation // Базовый контекст без conversation
interface MyContextBase extends Context, SessionFlavor<SessionData> { interface MyContextBase {
userDoc?: UserDocument; userDoc?: UserDocument;
} }
// Теперь создаём финальный тип контекста, дополняя его ConversationFlavor export type MyContext = ParseModeFlavor<Context> &
type MyContext = MyContextBase & ConversationFlavor<MyContextBase>; SessionFlavor<SessionData> &
ConversationFlavor &
export { MyContext }; MyContextBase;
+21
View File
@@ -0,0 +1,21 @@
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
/**
*
* Экранирует HTML-код, заменяя символы "<", ">", "&" на их соответствующие entity-кодировки.
*
* @param text
*/
/**
* Экранирует HTML-код, заменяя символы "<", ">", "&", '"' и "'" на их соответствующие entity-кодировки.
*
* @param text
*/
export function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+2 -2
View File
@@ -11,6 +11,6 @@
"declaration": true, // Генерация `.d.ts` файлов "declaration": true, // Генерация `.d.ts` файлов
"sourceMap": true, // Генерация `.map` файлов "sourceMap": true, // Генерация `.map` файлов
}, },
"include": ["src"], // Указываем папку с исходниками "include": ["globals.d.ts", "."], // Указываем папку с исходниками
"exclude": ["node_modules", "dist", "old"] // Исключаем ненужные директории "exclude": ["node_modules", "dist", "old"], // Исключаем ненужные директории
} }