create qr-codes by tg bot
Deploy app frontend / app frontend (push) Failing after 48s
Deploy db-migrations / db-migrations (push) Failing after 35s
Deploy service message-delivery-method-web-push / service message-delivery-method-web-push (push) Failing after 53s

This commit is contained in:
2024-12-10 13:21:38 +02:00
parent e9b3df38fe
commit 1810c550ba
91 changed files with 3317 additions and 1909 deletions
@@ -33,6 +33,10 @@ const dbsSecurity = {
admins: { roles: ["_admin"] },
members: { roles: [] },
},
tg_bot: {
admins: { roles: ["_admin"] },
members: { roles: [] },
},
};
if (!process.env.API_URL) {
@@ -94,7 +98,7 @@ export const down = async () => {
if (responseDeleteDb.ok) {
console.log(`deleted ${dbName}`);
} else {
throw new Error(
console.error(
`${dbName} delete error ${JSON.stringify(responseDeleteDb)}`,
);
}
@@ -0,0 +1,60 @@
import PouchDB from "../PouchDB.js";
const qrDb = new PouchDB(`${process.env.API_URL}/qr`, { skip_setup: true });
const messagesDb = new PouchDB(`${process.env.API_URL}/messages`, {
skip_setup: true,
});
const qrIndex = {
ddoc: "_design/idx-qr-type-user_uuid",
name: "idx-qr-type-user_uuid",
type: "json",
index: {
fields: ["type", "user_uuid"],
},
};
const messagesIndex = {
ddoc: "_design/idx-messages-type-qr_code_uri-chat",
name: "idx-messages-type-qr_code_uri-chat",
type: "json",
index: {
fields: ["type", "qr_code_uri", "chat"],
},
};
export const up = async () => {
try {
await qrDb.createIndex(qrIndex);
} catch (error) {
console.error("Ошибка при создании индекса для qrDb", qrIndex);
throw error;
}
try {
await messagesDb.createIndex(messagesIndex);
} catch (error) {
console.error("Ошибка при создании индекса для messagesDb", messagesIndex);
throw error;
}
};
export const down = async () => {
try {
await qrDb.deleteIndex(qrIndex);
} catch (error) {
console.error("Ошибка при удалении индекса для qrDb", qrIndex, error);
// Возможно, индекс не существует, просто логируем
}
try {
await messagesDb.deleteIndex(messagesIndex);
} catch (error) {
console.error(
"Ошибка при удалении индекса для messagesDb",
messagesIndex,
error,
);
// Возможно, индекс не существует, просто логируем
}
};
@@ -5,4 +5,4 @@ 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 ["bun", "dist/service.js"]
@@ -2,7 +2,7 @@
"name": "@hereconnect/service-message-delivery-method-web-push",
"version": "1.0.0",
"description": "",
"main": "dist/server.ts",
"main": "dist/service.ts",
"type": "module",
"license": "@hereconnect/license",
"private": true,
@@ -27,7 +27,7 @@
},
"contributors": [],
"scripts": {
"start": "node dist/server.js",
"start": "node dist/service.js",
"build": "tsc",
"lint": "tsc --noEmit",
"generate-keys": "node src/generate-keys.ts",
@@ -36,6 +36,8 @@
},
"dependencies": {
"@hereconnect/couchdb-changes-stream": "workspace:^",
"@hereconnect/lib-service-health-checkserver": "workspace:^",
"@hereconnect/lib-service-seq-tracker": "workspace:^",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-core": "^9.0.0",
"pouchdb-find": "^9.0.0",
@@ -1,64 +0,0 @@
import http from "http";
/**
* Запускает HTTP-сервер для проверки здоровья.
* @param controller - AbortController для управления состоянием здоровья.
* @param defaultPort - Порт для сервера по умолчанию (если не указан в ENV HEALTH_PORT).
* @param defaultHostName - Имя хоста для сервера по умолчанию (если не указан в ENV HEALTH_HOST).
*/
export function startHealthServer(
controller: AbortController,
defaultPort: number = 8000,
defaultHostName: string = "127.0.0.1",
) {
const port = parseInt(process.env.HEALTH_PORT || `${defaultPort}`, 10);
const hostname = process.env.HEALTH_HOST || defaultHostName;
const server = http.createServer((req, res) => {
if (req.url === "/health") {
const signal = controller.signal;
// Если сигнал отмены активирован, сервер помечается как "unhealthy"
if (signal.aborted) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: "unhealthy",
reason: "Service aborted",
}),
);
return;
}
// Возвращаем стандартный статус "ok", если всё нормально
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: "ok",
}),
);
} else {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
}
});
const { promise, resolve, reject } = Promise.withResolvers<void>();
server.once("error", reject);
server.listen(port, hostname, resolve);
promise.then(() => {
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.");
reject();
server.close();
});
return promise;
}
@@ -10,11 +10,12 @@ import {
webPushSubscriptionsDb,
userChatSettingsDb,
checkDatabases,
serviceSeqTrackerDb,
} from "./dbs";
import { setupVapidDetails } from "./setupVapidDetails";
import type { MessageDocument } from "@hereconnect/types";
import { ServiceSeqTracker } from "./serviceSeqTracker";
import { startHealthServer } from "./health-server";
import { ServiceSeqTracker } from "@hereconnect/lib-service-seq-tracker";
import { startHealthServer } from "@hereconnect/lib-service-health-checkserver";
const abortController = new AbortController();
@@ -33,6 +34,7 @@ const start = async () => {
const serviceSeqTracker = await ServiceSeqTracker.Init(
"_local/service:message-delivery-method-web-push",
serviceSeqTrackerDb,
);
const changesStream = new CouchDBChangesStream<MessageDocument>(
@@ -56,6 +58,7 @@ const start = async () => {
// Gracefully handle SIGINT
process.on("SIGINT", async () => {
changesStream?.stop();
setTimeout(() => process.exit(1), 5_000).unref();
});
console.info("Service started from sequence", serviceSeqTracker.lastSeq);
@@ -1,43 +0,0 @@
import { serviceSeqTrackerDb, type ServiceSeqTrackerDocument } from "./dbs";
export class ServiceSeqTracker {
/**
* @param _id
* @constructor
*/
static async Init(_id: string) {
const doc = await serviceSeqTrackerDb.get(_id).catch((error) => {
if (error.name === "not_found") {
return {
_id,
lastSeq: "0",
};
}
throw error; // Re-throw other errors
});
return new ServiceSeqTracker(doc);
}
private constructor(private doc: ServiceSeqTrackerDocument) {}
get lastSeq() {
return this.doc.lastSeq;
}
checkNextSeq(seq: number | string) {
if (!this.doc) {
}
const next = parseInt(seq as string, 10);
const current = parseInt(this.doc.lastSeq as string, 10);
if (next < current) {
throw new Error(
`Sequence number is too old: ${seq} (${next} < ${current})`,
);
}
}
saveSeq(seq: number | string) {
this.doc.lastSeq = seq;
return serviceSeqTrackerDb.put(this.doc);
}
}
@@ -0,0 +1,3 @@
{
"addExtension": "js"
}
@@ -1,14 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
"target": "ESNext", // Указываем современный стандарт
"module": "ESNext", // Используем ES модули
"moduleResolution": "node", // Разрешение модулей как в Node.js
"strict": true, // Включаем строгий режим
"outDir": "./dist", // Папка для вывода скомпилированных файлов
"rootDir": "./src", // Корневая папка исходников
"esModuleInterop": true, // Совместимость с CommonJS модулями
"skipLibCheck": true, // Пропуск проверки типов в библиотечных файлах
"declaration": true, // Генерация `.d.ts` файлов
"sourceMap": true, // Генерация `.map` файлов
"baseUrl": "./", // Базовая директория для путей
"paths": {
"*": ["src/*"] // Настройка алиасов (сопоставление путей)
}
},
"include": ["src"]
}
"include": ["src"], // Указываем папку с исходниками
"exclude": ["node_modules", "dist"] // Исключаем ненужные директории
}
+3
View File
@@ -0,0 +1,3 @@
src
tsconfig.service.json
*.log
+1
View File
@@ -0,0 +1 @@
dist
+8
View File
@@ -0,0 +1,8 @@
FROM oven/bun:1.1.38-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"]
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><path fill="#FB2500" stroke="None" d="M94 384c-10.69 10.9-19.59 23.73-29.75 35.25C57.4 429.31 33.45 446.87 54 455h65c61.67-2.91 126.65 7.03 185.33-7.67 117.65-27.97 185.84-172.81 132.59-281.25C390.54 54.32 238.47 9.09 139.7 77.7 34.91 139.96 15.23 294.73 94 384m236-226v177c-26.16-6.68-51.76-15.48-78-22 8.51-24.46 20.5-47.86 29.98-72.02 5.09-16.89-18.53-19.16-25.73-6.73-18.43 15.59-37.17 30.71-55.5 46.5-9.63 13.08-22.66.79-34.43-2.07-12.21-5.3-25.45-9.3-37.32-14.68 66.54-35.63 133.64-72.07 201-106Z"/></svg>

After

Width:  |  Height:  |  Size: 573 B

+25
View File
@@ -0,0 +1,25 @@
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);
});
+74
View File
@@ -0,0 +1,74 @@
{
"name": "@hereconnect/service-tg-bot",
"version": "1.0.0",
"description": "",
"main": "dist/server.ts",
"type": "module",
"license": "@hereconnect/license",
"private": true,
"engines": {
"node": ">=v20.10.0",
"pnpm": ">=8.15.0"
},
"homepage": "",
"repository": {
"type": "git",
"url": ""
},
"files": [
"dist/**/*"
],
"bugs": "",
"keywords": [],
"author": {
"name": "",
"email": "",
"url": ""
},
"contributors": [],
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"validate": "npm run type-check && npm run lint",
"start": "node --import=extensionless/register dist/bot/index.js"
},
"dependencies": {
"@hereconnect/constants": "workspace:^",
"@hereconnect/couchdb-changes-stream": "workspace:^",
"@hereconnect/lib-service-health-checkserver": "workspace:^",
"@hereconnect/lib-service-seq-tracker": "workspace:^",
"@hereconnect/tg-bot-webhook-ddoc": "workspace:^",
"@xmldom/xmldom": "^0.9.6",
"canvas": "^2.11.2",
"extensionless": "^1.9.9",
"grammy": "^1.33.0",
"jsdom": "^25.0.1",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-core": "^9.0.0",
"pouchdb-find": "^9.0.0",
"pouchdb-upsert": "^2.2.0",
"qr-code-styling": "^1.8.4",
"sharp": "^0.33.5",
"svgo": "^3.3.2"
},
"devDependencies": {
"@grammyjs/conversations": "^1.2.0",
"@grammyjs/types": "^3.17.0",
"@hereconnect/types": "workspace:^",
"@types/bun": "^1.1.14",
"@types/node": "^22.10.1",
"@types/pouchdb-adapter-http": "^6.1.6",
"@types/pouchdb-core": "^7.0.15",
"@types/pouchdb-find": "^7.3.3",
"@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",
"typescript": "^5.7.2"
}
}
+8
View File
@@ -0,0 +1,8 @@
import PouchDB from "pouchdb-core";
import PouchDBAdapterHttp from "pouchdb-adapter-http";
import PouchDBFindPlugin from "pouchdb-find";
import PouchDBUpsertPlugin from "pouchdb-upsert";
export default PouchDB.plugin(PouchDBAdapterHttp)
.plugin(PouchDBFindPlugin)
.plugin(PouchDBUpsertPlugin);
+92
View File
@@ -0,0 +1,92 @@
import { qrDb } from "../dbs";
import type { QRCodeDocument } from "@hereconnect/types";
import { EPOCH } from "@hereconnect/constants";
import { env } from "../config/env"; // Предполагается, что здесь хранится READ_BASE_URL
/**
* Получает документ QR-кода по его _id.
* @param qrCodeUri - Идентификатор документа QR-кода
*/
export function getQRCodeByUri(qrCodeUri: string) {
// Если документ не будет найден, будет брошена ошибка с name === "not_found"
// или любая другая ошибка будет проброшена дальше, без перехвата.
return qrDb.get(`qr_code:${qrCodeUri}`);
}
/**
* Удаляет документ QR-кода по его _id, используя механику _deleted: true.
* @param qrCodeUri - Идентификатор документа QR-кода.
*/
export async function deleteQRCode(qrCodeUri: string): Promise<void> {
const doc = await getQRCodeByUri(qrCodeUri);
if (!doc) {
throw new Error(`QR-код $(qrCodeUri}" не найден`);
}
const deletedDoc = {
...doc,
_deleted: true, // Помечаем документ как удалённый
};
await qrDb.put(deletedDoc);
}
/**
* Возвращает список QR-кодов, принадлежащих данному пользователю.
* @param userUuid - UUID пользователя
*/
export async function getQRCodesByUser(userUuid: string) {
const result = await qrDb.find({
selector: {
type: "qr_code",
user_uuid: userUuid,
},
limit: Number.MAX_SAFE_INTEGER,
});
return result.docs;
}
interface CreateQRCodeOptions {
userUuid: string;
name: string;
placement: QRCodeDocument["placement"];
actions: string[];
messageDeliveryMethod?: QRCodeDocument["messageDeliveryMethod"];
browser_uuid?: string;
telegram_id?: string;
}
/**
* Создаёт новый QR-код документ в базе.
* Генерирует уникальный URI, сохраняет документ и возвращает объект с uri.
*/
export async function createQRCode(options: CreateQRCodeOptions) {
const now = new Date().toISOString();
// Генерируем uri на основе текущего времени относительно EPOCH
const uri = Math.abs(Date.now() - EPOCH).toString(36);
const url = `${env.READ_BASE_URL}/${uri}`;
const newDoc: QRCodeDocument = {
_id: `qr_code:${uri}`,
type: "qr_code",
created_at: now,
user_uuid: options.userUuid,
name: options.name,
placement: options.placement,
actions: options.actions,
uri,
url,
messageDeliveryMethod: options.messageDeliveryMethod || "telegram",
browser_uuid: options.browser_uuid,
telegram_id: options.telegram_id,
};
const resp = await qrDb.put(newDoc);
if (!resp.ok) {
throw new Error("Не удалось создать QR-код");
}
newDoc._rev = resp.rev;
return newDoc;
}
+76
View File
@@ -0,0 +1,76 @@
import { usersDb } from "../dbs";
import type { UserDocument } from "@hereconnect/types";
import { getIsoDate } from "../utils/date";
import { randomUUID } from "crypto";
/**
* Ищет или создаёт пользователя по telegram_id.
* @param telegramId - числовой Telegram ID
* @param options - дополнительные поля, например email
*/
export async function getOrCreateUserByTelegramId(
telegramId: number,
options?: { email?: string },
): Promise<UserDocument> {
const docId = `org.couchdb.user:telegram_${telegramId}`;
// Пытаемся получить пользователя по docId
let existingUser: UserDocument | undefined;
try {
const doc = await usersDb.get<UserDocument>(docId);
existingUser = doc;
} catch (error: any) {
if (error.name !== "not_found") {
// Если ошибка не "not_found", пробрасываем дальше
throw error;
}
// Если not_found, то пользователя нет, создадим
}
if (existingUser) {
// Пользователь уже существует
return existingUser;
}
// Создаем нового пользователя
const now = getIsoDate();
const userUuid = randomUUID();
const password = randomUUID();
const newUser: UserDocument = {
_id: docId,
type: "user",
created_at: now,
telegram_id: telegramId,
name: `telegram_${telegramId}`,
user_uuid: userUuid,
roles: ["user"],
password,
email: options?.email,
};
// Создаём документ в базе
const response = await usersDb.put(newUser);
if (!response.ok) {
throw new Error("Не удалось создать пользователя (put)");
}
// Обновляем _rev
newUser._rev = response.rev;
return newUser;
}
/**
* Гарантирует, что пользователь с указанным Telegram ID существует.
* Если пользователь не существует, создаёт его. Возвращает документ пользователя.
* @param telegramId - числовой Telegram ID
* @param email - дополнительный параметр, если нужно
*/
export async function ensureUserExists(
telegramId: number,
email?: string,
): Promise<UserDocument> {
// Вызов уже реализованной функции getOrCreateUserByTelegramId
const userDoc = await getOrCreateUserByTelegramId(telegramId, { email });
return userDoc;
}
@@ -0,0 +1,48 @@
import { MyContext } from "../../types/myContext";
import { getQRCodeByUri } from "../../api/qrCodes";
export async function manageMenuActions(ctx: MyContext) {
const data = ctx.callbackQuery?.data;
if (!data || !data.startsWith("manage:")) {
return; // Игнорируем нерелевантные данные
}
const qrCodeUri = data.split(":")[1];
try {
const qrCode = await getQRCodeByUri(qrCodeUri);
if (!qrCode) {
await ctx.answerCallbackQuery({ text: "QR-код не найден." });
return;
}
// Отображаем подменю для управления выбранным QR-кодом
await ctx.editMessageText(
`Вы выбрали QR-код: "${qrCode.name}". Что вы хотите сделать?`,
{
reply_markup: {
inline_keyboard: [
[
{ text: "Информация", callback_data: `manage:info:${qrCodeUri}` },
{
text: "Показать QR-код",
callback_data: `manage:show:${qrCodeUri}`,
},
],
[
{
text: "❌ Удалить",
callback_data: `manage:delete:${qrCodeUri}`,
},
],
],
},
},
);
} catch (error) {
console.error("Ошибка при обработке QR-кода:", qrCodeUri, error);
await ctx.answerCallbackQuery({ text: "Произошла ошибка." });
}
}
@@ -0,0 +1,78 @@
import { deleteQRCode, getQRCodeByUri } from "../../api/qrCodes";
import { MyContext } from "../../types/myContext";
import { generateQRCodeImage } from "../../services/qrGenerator";
import { InputFile } from "grammy";
export async function qrCodeActions(ctx: MyContext) {
const data = ctx.callbackQuery?.data;
if (!data) {
await ctx.answerCallbackQuery({ text: "Неверные данные действия." });
return;
}
const parts = data.split(":");
if (parts.length < 3) {
await ctx.answerCallbackQuery({ text: "Неверный формат данных действия." });
return;
}
const action = parts[1];
const qrCodeUri = parts[2];
// Получаем документ QR-кода
let qrCode;
try {
qrCode = await getQRCodeByUri(qrCodeUri);
if (!qrCode) {
await ctx.answerCallbackQuery({ text: "QR-код не найден." });
return;
}
} catch (error) {
console.error("Ошибка при получении QR-кода:", qrCodeUri, error);
await ctx.answerCallbackQuery({ text: "Ошибка при обработке QR-кода." });
return;
}
// Обработка действия
switch (action) {
case "info":
await ctx.reply(
`Информация о QR-коде:\n\nНазвание: ${qrCode.name}\nURL: ${qrCode.url}`,
);
break;
case "delete":
try {
await deleteQRCode(qrCode.uri);
await ctx.editMessageText(`QR-код "${qrCode.name}" успешно удалён.`);
} catch (error) {
console.error("Ошибка при удалении QR-кода:", error);
await ctx.answerCallbackQuery({ text: "Ошибка при удалении QR-кода." });
}
break;
case "show":
try {
// Генерация QR-кода
const pngBuffer = await generateQRCodeImage(qrCode.url, {
format: "png",
});
// Отправка QR-кода
await ctx.replyWithPhoto(
new InputFile(new Uint8Array(pngBuffer.buffer), "qr.png"),
);
} catch (error) {
console.error("Ошибка при генерации QR-кода:", error);
await ctx.answerCallbackQuery({
text: "Ошибка при отображении QR-кода.",
});
}
break;
default:
await ctx.answerCallbackQuery({ text: "Неизвестное действие." });
}
}
@@ -0,0 +1,27 @@
import { MyContext } from "../types/myContext";
import { manageMenuActions } from "./actions/manageMenuActions";
import { qrCodeActions } from "./actions/qrCodeActions";
/**
* Обработка callback_query для управления QR-кодами
*/
export async function callbackQueryHandler(ctx: MyContext) {
const data = ctx.callbackQuery?.data;
if (!data) return; // Игнорируем пустые callback queries
if (
data.startsWith("manage:info:") ||
data.startsWith("manage:show:") ||
data.startsWith("manage:delete:")
) {
// Обработка действий с QR-кодами (информация, показать, удалить)
await qrCodeActions(ctx);
} else if (data.startsWith("manage:")) {
// Обработка выбора QR-кода
await manageMenuActions(ctx);
} else {
// Ответ на неизвестные callback queries
await ctx.answerCallbackQuery({ text: "Неизвестное действие." });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { MyContext } from "../../types/myContext";
export async function helpCommand(ctx: MyContext) {
await ctx.reply(
"Список доступных команд:\n" +
"/start - начать работу с ботом\n" +
"/help - показать помощь\n" +
"/new - создать новый QR-код\n" +
"/manage - управлять вашими QR-кодами\n",
);
}
@@ -0,0 +1,44 @@
import { MyContext } from "../../types/myContext";
import { getQRCodesByUser } from "../../api/qrCodes";
/**
* Обработка команды /manage с выводом подменю
*/
export async function manageCommand(ctx: MyContext) {
if (!ctx.userDoc) {
await ctx.reply(
"Ваш профиль не найден. Попробуйте снова или обратитесь в поддержку.",
);
return;
}
try {
const qrCodes = await getQRCodesByUser(ctx.userDoc.user_uuid);
if (qrCodes.length === 0) {
await ctx.reply(
"У вас ещё нет QR-кодов. Используйте команду /new для создания.",
);
return;
}
// Генерация клавиатуры с QR-кодами
const keyboard = qrCodes.map((qr) => [
{
text: qr.name,
callback_data: `manage:${qr.uri}`,
},
]);
await ctx.reply("Выберите QR-код для управления:", {
reply_markup: {
inline_keyboard: keyboard,
},
});
} catch (error) {
console.error("Ошибка при получении QR-кодов:", error);
await ctx.reply(
"Произошла ошибка при загрузке ваших QR-кодов. Попробуйте позже.",
);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { MyContext } from "../../types/myContext";
export async function newCommand(ctx: MyContext) {
// Запускаем многошаговый сценарий создания нового QR-кода
// conversation: "newQRCodeConversation"
await ctx.conversation.enter("newQRCodeConversation");
}
@@ -0,0 +1,37 @@
import { Bot } from "grammy";
import { MyContext } from "../../types/myContext";
import { bot } from "old_bot"; // Ваш кастомный контекст
/**
* Устанавливает команды для бота.
* @param bot - Экземпляр бота.
*/
export async function setupCommands(bot: Bot<MyContext>): Promise<void> {
// Настройка команд для английского языка (по умолчанию)
await bot.api.setMyCommands([]);
// Настройка команд для русского языка
await bot.api.setMyCommands(
[
{
command: "start",
description: "Запустить бота",
},
{
command: "help",
description: "Получить помощь",
},
{
command: "new",
description: "Создать новый QR-код",
},
{
command: "manage",
description: "Управлять QR-кодами",
},
],
{ language_code: "ru" },
);
console.log("Команды успешно установлены.");
}
+23
View File
@@ -0,0 +1,23 @@
import { MyContext } from "../../types/myContext";
import { ensureUserExists } from "../../api/users";
export async function startCommand(ctx: MyContext) {
const userId = ctx.from?.id;
if (userId === undefined) {
await ctx.reply(
"Не удалось определить ваш идентификатор пользователя. Попробуйте позже или обратитесь в поддержку.",
);
return;
}
// Проверяем или создаем пользователя в БД
await ensureUserExists(userId);
await ctx.reply(
"Привет! Я бот сервиса «НаСвязи». Я помогу вам создавать и настраивать QR-коды.",
);
await ctx.reply(
"Используйте команду /new чтобы создать новый QR-код или /manage для управления уже созданными кодами.",
);
}
@@ -0,0 +1,116 @@
import { type QRCodeDocument } from "@hereconnect/types";
import { Conversation } from "@grammyjs/conversations";
import { InputFile } from "grammy";
import type { MyContext } from "../../types/myContext";
import { qrCodePlacements } from "../../models/types";
import {
buildPlacementKeyboard,
buildActionsKeyboard,
} from "../utils/keyboards";
import { createQRCode } from "../../api/qrCodes";
import { generateQRCodeImage } from "../../services/qrGenerator";
// Тип разговора (конверсии)
type MyConversation = Conversation<MyContext>;
export async function newQRCodeConversation(
conversation: MyConversation,
ctx: MyContext,
) {
if (!ctx.userDoc) {
await ctx.reply(
"Не удалось определить вашего пользователя. Попробуйте позже.",
);
return;
}
const userUuid = ctx.userDoc.user_uuid;
if (!userUuid) {
await ctx.reply(
"У вашего профиля отсутствует UUID. Обратитесь в поддержку.",
);
return;
}
// 1. Выбор типа объекта (placement)
await ctx.reply("Выберите объект, для которого будет создан QR-код:", {
reply_markup: {
inline_keyboard: buildPlacementKeyboard(qrCodePlacements),
},
});
const placementChoice = await conversation.waitFor("callback_query:data");
const placement = placementChoice.update.callback_query?.data;
// Проверяем, что выбранное значение входит в допустимые типы
if (!qrCodePlacements.some((p) => p.type === placement)) {
await ctx.reply("Неверный выбор. Попробуйте снова /new");
return;
}
// Преобразуем `placement` в строго типизированное значение
const typedPlacement = placement as QRCodeDocument["placement"];
// Подтверждаем выбор
await placementChoice.answerCallbackQuery({ text: "Объект выбран!" });
// 2. Ввод названия QR-кода
await ctx.reply(
"Введите название для вашего QR-кода (например: 'Моя машина'):",
);
const nameMsg = await conversation.waitFor("message:text");
const name = nameMsg.message.text.trim();
if (name.length < 2) {
await ctx.reply(
"Название должно содержать минимум 2 символа. Попробуйте снова /new.",
);
return;
}
// 3. Выбор действий, доступных при сканировании
await ctx.reply("Выберите доступные действия:", {
reply_markup: {
inline_keyboard: buildActionsKeyboard([
{ action: "send_message", label: "Отправить сообщение" },
{ action: "show_phone", label: "Показать номер телефона" },
]),
},
});
const actionsMsg = await conversation.waitFor("callback_query:data");
const chosenAction = actionsMsg.update.callback_query?.data;
if (!chosenAction) {
await ctx.reply("Вы не выбрали действие. Попробуйте снова /new.");
return;
}
// Подтверждаем выбор
await actionsMsg.answerCallbackQuery({ text: "Действие выбрано!" });
// Пока для MVP можно выбрать только одно действие, в будущем можно расширить
const chosenActions = [chosenAction];
// 4. Создание документа в БД
const { url, uri } = await createQRCode({
userUuid,
name,
placement: typedPlacement,
actions: chosenActions,
});
// 5. Генерация QR-кода
const pngBuffer = await generateQRCodeImage(url, { format: "png" });
// 6. Отправляем пользователю QR-код
await ctx.reply("Ваш QR-код готов! Вот он:");
await ctx.replyWithPhoto(
new InputFile(new Uint8Array(pngBuffer.buffer), "qr.png"),
);
await ctx.reply(
`Разместите этот код на вашем объекте. При сканировании по адресу ${url} посетители смогут взаимодействовать с вами.`,
);
// Завершаем разговор
}
+45
View File
@@ -0,0 +1,45 @@
import { Bot, session } from "grammy";
import { conversations, createConversation } from "@grammyjs/conversations";
import { MyContext } from "../types/myContext"; // Ваш расширенный контекст
import { env } from "../config/env";
import { loadUserMiddleware } from "./middleware/loadUser";
import { setupCommands } from "./commands/setupCommands";
import { startCommand } from "./commands/start";
import { helpCommand } from "./commands/help";
import { newCommand } from "./commands/new";
import { manageCommand } from "./commands/manage";
import { callbackQueryHandler } from "./callbackHandlers";
import { newQRCodeConversation } from "./conversations/newQRCodeConversation";
// Инициализируем бота с нужным типом контекста
const bot = new Bot<MyContext>(env.TELEGRAM_BOT_TOKEN);
// Подключаем сессии. По необходимости определите initial state для сессии
bot.use(session({ initial: () => ({}) }));
// Подключаем conversations для многошаговых сценариев
bot.use(conversations());
bot.use(createConversation(newQRCodeConversation, "newQRCodeConversation"));
// Middleware для загрузки или создания пользователя
bot.use(loadUserMiddleware);
// Регистрация команд
bot.command("start", startCommand);
bot.command("help", helpCommand);
bot.command("new", newCommand);
bot.command("manage", manageCommand);
// Обработка callback_query
bot.on("callback_query:data", callbackQueryHandler);
(async () => {
// Установка команд
await setupCommands(bot);
// Запуск бота
bot.start();
console.log("Бот запущен!");
})();
export { bot };
@@ -0,0 +1,36 @@
import { MiddlewareFn } from "grammy";
import { MyContext } from "../../types/myContext";
import { getOrCreateUserByTelegramId } from "../../api/users";
/**
* Данный middleware пытается загрузить пользователя из базы данных
* по его Telegram ID, а если пользователя ещё нет - создаёт.
* Результат сохраняется в ctx.userDoc, чтобы использовать далее в логике бота.
*/
export const loadUserMiddleware: MiddlewareFn<MyContext> = async (
ctx,
next,
) => {
// Если апдейт не содержит from или это системное сообщение от Telegram
if (!ctx.from) {
return next();
}
const telegramId = ctx.from.id;
try {
// Пытаемся получить или создать пользователя по Telegram ID
const userDoc = await getOrCreateUserByTelegramId(telegramId);
ctx.userDoc = userDoc; // сохраняем пользователя в контекст
} catch (error) {
console.error("Ошибка при загрузке пользователя:", error);
// Можно отправить пользователю сообщение об ошибке или пропустить
await ctx.reply(
"Произошла ошибка при загрузке ваших данных. Попробуйте позже.",
);
return; // Прекращаем обработку, если критическая ошибка
}
// Переходим к следующему middleware или обработчику
return next();
};
@@ -0,0 +1,35 @@
import { InlineKeyboardButton } from "grammy/types";
export function buildPlacementKeyboard(
placements: { type: string; label: string }[],
): InlineKeyboardButton[][] {
return placements.map((p) => [
{
text: p.label,
callback_data: p.type,
},
]);
}
export function buildActionsKeyboard(
actions: { action: string; label: string }[],
): InlineKeyboardButton[][] {
// Для простоты пусть выбирать только один вариант. Можно усложнить по необходимости.
return actions.map((a) => [
{
text: a.label,
callback_data: a.action,
},
]);
}
export function buildManageKeyboard(
qrCodes: { uri: string; name: string }[],
): InlineKeyboardButton[][] {
return qrCodes.map((qr) => [
{
text: qr.name,
callback_data: `manage:${qr.uri}:delete`, // Пример - сразу кнопка на удаление. Можно сделать отдельное меню.
},
]);
}
+12
View File
@@ -0,0 +1,12 @@
if (!process.env.BOT_TOKEN) {
throw new Error("Необходимо указать BOT_TOKEN в переменных окружения.");
}
if (!process.env.READ_BASE_URL) {
throw new Error("Необходимо указать READ_BASE_URL в переменных окружения.");
}
export const env = {
TELEGRAM_BOT_TOKEN: process.env.BOT_TOKEN,
READ_BASE_URL: process.env.READ_BASE_URL,
} as const;
+108
View File
@@ -0,0 +1,108 @@
import PouchDB from "./PouchDB";
import { ServiceSeqTrackerDatabase } from "@hereconnect/lib-service-seq-tracker";
import type {
MessageDocument,
QRCodeDocument,
MessageDeliveryMethodWebPushVapid,
UserChatSettingsDocument,
WebPushSubscriptionDocument,
PublicSettingsDocument,
UserDocument,
} from "@hereconnect/types";
import type { TelegramWebhookDesignDocument } from "@hereconnect/tg-bot-webhook-ddoc";
// import type { TgUpdateDocument } from "./types";
if (!process.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 messagesDb = new PouchDB<MessageDocument>(
`${process.env.API_URL}/messages`,
{
skip_setup: true,
},
);
export const serverSettingsDb = new PouchDB<MessageDeliveryMethodWebPushVapid>(
`${process.env.API_URL}/server_settings`,
{
skip_setup: true,
},
);
export const publicSettingsDb = new PouchDB<PublicSettingsDocument>(
`${process.env.API_URL}/public_settings`,
{
skip_setup: true,
},
);
export const qrDb = new PouchDB<QRCodeDocument>(`${process.env.API_URL}/qr`, {
skip_setup: true,
});
export const webPushSubscriptionsDb = new PouchDB<
WebPushSubscriptionDocument<PushSubscription>
>(`${process.env.API_URL}/web_push_subscriptions`, {
skip_setup: true,
});
export const tgBotDb = new PouchDB(TG_BOT_DB_URL, {
skip_setup: true,
});
export const tgBotDesignDocumentsDb =
tgBotDb as PouchDB.Database<TelegramWebhookDesignDocument>;
export const serviceSeqTrackerDb = tgBotDb as ServiceSeqTrackerDatabase;
export const userChatSettingsDb = new PouchDB<UserChatSettingsDocument>(
`${process.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 checkDatabases = async () => {
const entries = await Promise.all(
Object.entries({
serverSettingsDb,
publicSettingsDb,
qrDb,
webPushSubscriptionsDb,
serviceSeqTrackerDb,
userChatSettingsDb,
tgBotDb,
} as const).map(
async ([name, db]): Promise<
[string, Awaited<ReturnType<typeof db.info>>]
> => {
const info = await db.info();
return [name, info];
},
),
);
for (const [name, info] of entries) {
if ("error" in info && "reason" in info) {
console.error(info);
throw new Error(`[${name}] ${info.error}: ${info.reason}`);
}
if ("error" in info) {
throw new Error(`[${name}] ${info.error}`);
}
}
return Object.fromEntries(entries);
};
+2
View File
@@ -0,0 +1,2 @@
export type { UserDocument } from "@hereconnect/types";
export { qrCodePlacements } from "@hereconnect/constants";
+109
View File
@@ -0,0 +1,109 @@
import { Bot, Context, InlineKeyboard } from "grammy";
import { type AbortSignal } from "grammy/out/shim.node";
import { qrCodePlacements } from "@hereconnect/constants";
import type { QRCodeDocument } from "@hereconnect/types";
const BOT_TOKEN = process.env.BOT_TOKEN;
if (!BOT_TOKEN) {
throw new Error("Необходимо указать BOT_TOKEN в переменных окружения.");
}
export const bot = new Bot(BOT_TOKEN);
export const init = async (signal: AbortSignal) => {
await bot.init(signal);
// Настройка команд для английского языка (по умолчанию)
await bot.api.setMyCommands([
{ command: "create_qr", description: "Create a QR code" },
{ command: "help", description: "Get help" },
]);
// Настройка команд для русского языка
await bot.api.setMyCommands(
[
{ command: "create_qr", description: "Создать QR-код" },
{ command: "help", description: "Получить помощь" },
],
{ language_code: "ru" },
);
};
const actionsOptions = [
{
type: "sendMessage",
label: "Отправить сообщение",
},
{ type: "viewContactInfo", label: "Посмотреть ваши контактные данные" },
];
bot.command("start", async (ctx) => {
const user = ctx.from;
const message = `Привет${user?.first_name ? ", " + user?.first_name : ""}!\n\nЯ бот сервиса "НаСвязи". Помогу вам создать QR-код (для автомобиля, питомца, входной двери и т.д.).!`;
// Отправляем сообщение
return ctx.reply(message, {
reply_markup: {
inline_keyboard: [
[
{
text: "Создать QR-код",
callback_data: "create_qr",
},
],
],
},
});
});
const createQrCode = async (ctx: Context) => {
const keyboard = new InlineKeyboard();
for (const { type, label } of qrCodePlacements) {
keyboard.text(label, `select_placement=${type}`);
}
await ctx.reply(
"Где вы хотите разместить QR‑код?\n\nЭтот выбор поможет настроить QR‑код с подходящими действиями.",
{ reply_markup: { inline_keyboard: keyboard.toFlowed(1).inline_keyboard } },
);
};
bot.command("create_qr", createQrCode);
bot.callbackQuery("cancel", async (ctx) => {
await ctx.deleteMessage();
});
bot.callbackQuery(
/^(select_action=|select_placement=|create_qr$)/,
async (ctx) => {
const selectedActions: QRCodeDocument["actions"] = [];
const keyboard = new InlineKeyboard();
for (const { type, label } of actionsOptions) {
keyboard.text(
(selectedActions.includes(type) ? "✓ " : "") + label,
`select_action=${type}`,
);
}
await ctx.reply(
"Выберите доступные действия?\n\nУкажите, какие действия будут доступны для сканирующих QR‑код.\nВы сможете изменить эти настройки позже.",
{ reply_markup: { inline_keyboard: keyboard.inline_keyboard } },
);
},
);
if (process.argv.includes("start")) {
const abortController = new AbortController();
process.on("SIGINT", async () => {
abortController.abort();
setTimeout(() => process.exit(1), 1_000).unref();
});
init(abortController.signal as AbortSignal)
.then(() => bot.start())
.then(() => console.log("Bot started"))
.catch((error) => console.error(error));
}
+91
View File
@@ -0,0 +1,91 @@
import { startHealthServer } from "@hereconnect/lib-service-health-checkserver";
import { ServiceSeqTracker } from "@hereconnect/lib-service-seq-tracker";
import { type AbortSignal } from "grammy/out/shim.node";
import { setupWebHook } from "setup/setupWebHook";
const heartbeat = Number(process.env.HEARTBEAT_INTERVAL ?? 30_000);
import { CouchDBChangesStream } from "@hereconnect/couchdb-changes-stream";
import { TG_BOT_DB_URL, serviceSeqTrackerDb, checkDatabases } from "dbs";
import type { TgUpdateDocument } from "types";
import { init, bot } from "old_bot";
const abortController = new AbortController();
const start = async () => {
const checkedDatabases = await checkDatabases();
await init(abortController.signal as AbortSignal);
await setupWebHook();
const serviceSeqTracker = await ServiceSeqTracker.Init(
"_local/service:tg-bot",
serviceSeqTrackerDb,
);
const changesStream = new CouchDBChangesStream<TgUpdateDocument>(
TG_BOT_DB_URL,
{
abortController,
since: serviceSeqTracker.lastSeq,
feed: "continuous",
include_docs: true,
heartbeat,
filter: "_selector",
selector: {
type: "tg_update" satisfies TgUpdateDocument["type"],
},
},
);
// Gracefully handle SIGINT
process.on("SIGINT", async () => {
changesStream?.stop();
setTimeout(() => process.exit(1), 5_000).unref();
});
console.info("Service started from sequence", serviceSeqTracker.lastSeq);
if (
parseInt(serviceSeqTracker.lastSeq.toString(), 10) ===
parseInt(checkedDatabases.tgBotDb.update_seq.toString(), 10)
) {
console.log("Since is the last seq");
} else {
console.log("Last seq is", checkedDatabases.tgBotDb.update_seq);
}
await startHealthServer(abortController);
for await (const change of changesStream) {
const { seq, doc: changeDoc } = change;
if (!changeDoc) {
console.error("Change missing document:", change);
continue;
}
serviceSeqTracker.checkNextSeq(seq);
await bot.handleUpdate(changeDoc.data);
// const ctx = new Context(changeDoc.data, bot.api, bot.me);
// if (ctx.hasCommand("start")) {
// }
try {
console.log(
`Processed change: ${JSON.stringify({ _id: change.id, ref: changeDoc._rev, seq: change.seq })}`,
);
} catch (error) {
console.error(
`Failed processing: ${JSON.stringify(error)} change: ${JSON.stringify({ id: changeDoc._id, rev: changeDoc._rev, seq: seq })}`,
);
} finally {
await serviceSeqTracker.saveSeq(seq);
}
}
};
start().catch((error) => {
console.error("error", error);
process.exit(1);
});
@@ -0,0 +1,95 @@
import QRCodeStyling from "qr-code-styling";
import { optimizeSvg } from "../utils/images/svg/optimizeSvg";
import { replaceFillInSvg } from "../utils/images/svg/replaceFillInSvg";
import { addLabelToSvg } from "../utils/images/svg/addLabelToSvg";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import { JSDOM } from "jsdom";
import sharp from "sharp";
import { Buffer } from "buffer";
interface GenerateOptions {
format: "png" | "svg";
label?: string;
color?: string;
logoColor?: string;
width?: number;
height?: number;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export async function generateQRCodeImage(
uri: string,
{
format = "svg",
label = "Сообщите о проблеме или задайте вопрос",
color = "#000",
logoColor = "#000",
width = 1024,
height = 1024,
}: GenerateOptions,
): Promise<Buffer> {
const logoPath = path.resolve(__dirname, "../../assets/logo.svg");
let logoSvg = await fs.readFile(logoPath, "utf8");
// Заменяем цвет fill в лого
logoSvg = replaceFillInSvg(logoSvg, logoColor);
// Добавляем лейбл "НаСвязи" в логотип (если нужно)
const logoWithText = addLabelToSvg(logoSvg, "НаСвязи", logoColor, 0.01);
const qrCode = new QRCodeStyling({
jsdom: JSDOM, // this is required
width,
height,
data: uri,
image: `data:image/svg+xml;base64,${Buffer.from(logoWithText, "utf8").toString("base64")}`,
imageOptions: {
margin: 20,
imageSize: 0.45,
},
type: "svg",
backgroundOptions: {
color: "transparent",
},
cornersSquareOptions: {
type: "extra-rounded",
},
cornersDotOptions: {
type: "dot",
},
dotsOptions: {
color,
type: "classy-rounded",
},
});
const rawData = await qrCode.getRawData("svg");
if (!rawData || !(rawData instanceof Buffer)) {
throw new Error("Не удалось получить данные QR-кода");
}
// Преобразуем SVG в строку
const svgText = rawData.toString("utf8");
let optimizedSvg = optimizeSvg(svgText);
if (!optimizedSvg) {
throw new Error("Ошибка оптимизации SVG");
}
// Добавляем текст, если указано
if (label) {
optimizedSvg = addLabelToSvg(optimizedSvg, label, color);
}
if (format === "svg") {
// Если формат SVG, возвращаем буфер SVG
return Buffer.from(optimizedSvg, "utf8");
}
// Если формат PNG, преобразуем optimizedSvg в PNG через sharp
return sharp(Buffer.from(optimizedSvg, "utf8")).toFormat("png").toBuffer();
}
+23
View File
@@ -0,0 +1,23 @@
import { setupWebhookDesignDocument } from "./setupWebhookDesignDocument";
import { TG_BOT_DB_NAME, tgBotDesignDocumentsDb } from "dbs";
import { bot } from "old_bot";
const PUBLIC_API_URL = process.env.PUBLIC_API_URL;
if (!PUBLIC_API_URL) {
throw new Error("Необходимо указать PUBLIC_API_URL в переменных окружения.");
}
export const setupWebHook = async () => {
const designDocument = await setupWebhookDesignDocument();
const update: keyof typeof designDocument.updates = "saveUpdate";
const webhookUrl = `${process.env.PUBLIC_API_URL}/${TG_BOT_DB_NAME}/${designDocument._id}/_update/${update}`;
if (designDocument.constants.telegram_webhook_url !== webhookUrl) {
await bot.api.setWebhook(webhookUrl, {
secret_token: designDocument.constants.telegram_secret_token,
});
designDocument.constants.telegram_webhook_url = webhookUrl;
const { rev } = await tgBotDesignDocumentsDb.put(designDocument);
designDocument._rev = rev;
}
return designDocument;
};
@@ -0,0 +1,68 @@
import { tgBotDesignDocumentsDb } from "../dbs";
import { saveUpdateString as saveUpdate } from "@hereconnect/tg-bot-webhook-ddoc";
import { type TelegramWebhookDesignDocument } from "@hereconnect/tg-bot-webhook-ddoc";
function generateTelegramSecretToken(length = 256) {
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
let token = "";
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * characters.length);
token += characters[randomIndex];
}
return token;
}
export const setupWebhookDesignDocument = async () => {
const _id = "_design/webhook";
const result: TelegramWebhookDesignDocument = {
_id,
type: "_design/webhook",
created_at: "",
updated_at: "",
updates: {
saveUpdate,
},
constants: {
telegram_secret_token: "",
},
};
const { rev } = await tgBotDesignDocumentsDb.upsert(_id, (ddoc) => {
const isEmptyTelegramSecretToken =
!ddoc.constants?.telegram_secret_token?.length;
const needToSetSaveUpdateFunction =
ddoc.updates?.saveUpdate !== result.updates.saveUpdate;
if (!needToSetSaveUpdateFunction && !isEmptyTelegramSecretToken) {
Object.assign(result, ddoc);
return false;
}
ddoc.updated_at = new Date().toISOString();
if (!ddoc.created_at) {
ddoc.created_at = ddoc.updated_at;
}
if (isEmptyTelegramSecretToken) {
if (!ddoc.constants) {
ddoc.constants = result.constants;
}
ddoc.constants.telegram_secret_token = generateTelegramSecretToken();
}
if (needToSetSaveUpdateFunction) {
if (ddoc.updates) {
ddoc.updates.saveUpdate = result.updates.saveUpdate;
} else {
ddoc.updates = result.updates;
}
}
return Object.assign(result, ddoc) satisfies TelegramWebhookDesignDocument;
});
result._rev = rev;
return result;
};
+27
View File
@@ -0,0 +1,27 @@
import type { BaseDocument } from "@hereconnect/types";
import type { Update } from "@grammyjs/types";
/**
* Интерфейс для документа CouchDB, представляющего обновление Telegram
*/
export interface TgUpdateDocument extends BaseDocument {
/**
* Уникальный идентификатор документа, например, "tg_update:<update_id>"
*/
_id: string;
/**
* Ревизия документа (если документ уже существует, автоматически добавляется CouchDB)
*/
_rev?: string;
/**
* Тип документа для идентификации (фиксированное значение "tg_update")
*/
type: "tg_update";
/**
* Данные обновления Telegram
*/
data: Update;
}
+19
View File
@@ -0,0 +1,19 @@
// src/types/myContext.ts
import { Context, SessionFlavor } from "grammy";
import { ConversationFlavor } from "@grammyjs/conversations";
import type { UserDocument } from "@hereconnect/types";
interface SessionData {
step?: number;
}
// Базовый контекст без conversation
interface MyContextBase extends Context, SessionFlavor<SessionData> {
userDoc?: UserDocument;
}
// Теперь создаём финальный тип контекста, дополняя его ConversationFlavor
type MyContext = MyContextBase & ConversationFlavor<MyContextBase>;
export { MyContext };
+4
View File
@@ -0,0 +1,4 @@
// src/utils/date.ts
export function getIsoDate(): string {
return new Date().toISOString();
}
@@ -0,0 +1,61 @@
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
import { calculateFontSizeForText } from "./calculateFontSizeForText";
/**
* Добавление текста в SVG, прибитого к нижнему краю.
* @param svgText Исходный SVG в виде строки.
* @param label Надпись для добавления.
* @param color Цвет текста.
* @param marginMultiplier Множитель относительного отступа.
* @returns SVG с добавленным текстом.
*/
export function addLabelToSvg(
svgText: string,
label: string,
color: string,
marginMultiplier: number = 0.1,
): string {
if (!label) {
return svgText;
}
const parser = new DOMParser();
const svgDoc = parser.parseFromString(svgText, "image/svg+xml");
const svgElement = svgDoc.getElementsByTagName("svg").item(0);
if (svgElement) {
const viewBoxAttr = svgElement.getAttribute("viewBox");
if (viewBoxAttr) {
const viewBox = viewBoxAttr.split(" ").map(Number);
if (viewBox.length === 4) {
// Рассчитываем размер шрифта
const fontSize = calculateFontSizeForText(label, viewBox[2]);
const textMargin = Math.min(fontSize, viewBox[3] * marginMultiplier);
// Увеличиваем высоту viewBox
viewBox[3] += fontSize + textMargin;
svgElement.setAttribute("viewBox", viewBox.join(" "));
// Создаем элемент текста
const textElement = svgDoc.createElementNS(
"http://www.w3.org/2000/svg",
"text",
);
textElement.setAttribute("x", "50%");
textElement.setAttribute("y", "100%");
textElement.setAttribute("text-anchor", "middle");
textElement.setAttribute("alignment-baseline", "baseline");
textElement.setAttribute("dominant-baseline", "text-after-edge");
textElement.setAttribute("fill", color);
textElement.setAttribute("font-size", fontSize.toString());
textElement.setAttribute("font-family", "Arial, sans-serif");
textElement.textContent = label;
svgElement.appendChild(textElement);
}
}
}
const serializer = new XMLSerializer();
return serializer.serializeToString(svgDoc);
}
@@ -0,0 +1,36 @@
import { createCanvas } from "canvas";
/**
* Подбирает размер шрифта, чтобы текст вписался в maxWidth.
* Использует canvas для измерения ширины текста.
*
* @param text Текст, размер которого нужно подобрать.
* @param maxWidth Максимальная ширина текста.
* @returns Подходящий размер шрифта в px.
*/
export function calculateFontSizeForText(
text: string,
maxWidth: number,
): number {
// Создаем canvas для измерения текста
const canvas = createCanvas(10, 10); // createCanvas(10, 10);
const ctx = canvas.getContext("2d");
// Начальный размер шрифта — можно начать, например, с maxWidth
// или с какого-то разумного начального размера.
let fontSize = Math.floor(maxWidth);
while (fontSize > 0) {
ctx.font = `${fontSize}px Arial, sans-serif`;
const metrics = ctx.measureText(text);
const textWidth = metrics.width;
if (textWidth <= maxWidth) {
break; // нашли подходящий размер
}
fontSize -= 1; // уменьшаем шрифт на 1px и повторяем
}
return fontSize > 0 ? fontSize : 1; // гарантия, что не вернем 0 или отрицательное число
}
@@ -0,0 +1,25 @@
import { optimize } from "svgo";
/**
* Оптимизация SVG с использованием SVGO.
* @param svgText string изображения SVG.
* @returns Строка оптимизированного SVG.
*/
export function optimizeSvg(svgText: string): string {
const optimizedSvg = optimize(svgText, {
plugins: [
{ name: "removeDoctype" },
{ name: "removeComments" },
{ name: "removeDimensions" },
{ name: "convertShapeToPath" },
{ name: "convertPathData" },
{ name: "mergePaths" },
],
});
if (!optimizedSvg.data) {
throw new Error("Ошибка оптимизации SVG");
}
return optimizedSvg.data;
}
@@ -0,0 +1,23 @@
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
/**
* Заменяет атрибут `fill` в SVG-коде.
* @param svgText Исходный SVG в виде строки.
* @param fillColor Новый цвет для fill.
* @returns Измененный SVG-код.
*/
export function replaceFillInSvg(svgText: string, fillColor: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(svgText, "image/svg+xml");
const elements = doc.getElementsByTagName("*");
for (let i = 0; i < elements.length; i++) {
const el = elements.item(i);
if (el && el.hasAttribute("fill")) {
el.setAttribute("fill", fillColor);
}
}
const serializer = new XMLSerializer();
return serializer.serializeToString(doc);
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext", // Указываем современный стандарт
"module": "ESNext", // Используем ES модули
"moduleResolution": "node", // Разрешение модулей как в Node.js
"strict": true, // Включаем строгий режим
"outDir": "./dist", // Папка для вывода скомпилированных файлов
"rootDir": "./src", // Корневая папка исходников
"esModuleInterop": true, // Совместимость с CommonJS модулями
"skipLibCheck": true, // Пропуск проверки типов в библиотечных файлах
"declaration": true, // Генерация `.d.ts` файлов
"sourceMap": true, // Генерация `.map` файлов
"baseUrl": "./", // Базовая директория для путей
"paths": {
"*": ["src/*"] // Настройка алиасов (сопоставление путей)
}
},
"include": ["src"], // Указываем папку с исходниками
"exclude": ["node_modules", "dist"] // Исключаем ненужные директории
}