create qr-codes by tg bot
This commit is contained in:
@@ -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;
|
||||
}
|
||||
+5
-2
@@ -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"] // Исключаем ненужные директории
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
src
|
||||
tsconfig.service.json
|
||||
*.log
|
||||
@@ -0,0 +1 @@
|
||||
dist
|
||||
@@ -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"]
|
||||
@@ -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 |
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: "Неизвестное действие." });
|
||||
}
|
||||
}
|
||||
@@ -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-кодов. Попробуйте позже.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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("Команды успешно установлены.");
|
||||
}
|
||||
@@ -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} посетители смогут взаимодействовать с вами.`,
|
||||
);
|
||||
|
||||
// Завершаем разговор
|
||||
}
|
||||
@@ -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`, // Пример - сразу кнопка на удаление. Можно сделать отдельное меню.
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { UserDocument } from "@hereconnect/types";
|
||||
export { qrCodePlacements } from "@hereconnect/constants";
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"] // Исключаем ненужные директории
|
||||
}
|
||||
Reference in New Issue
Block a user