add tg_bot_sessions, padding to qrcode
Deploy app frontend / app frontend (push) Failing after 49s
Deploy db-migrations / db-migrations (push) Failing after 39s
Deploy service message-delivery-method-web-push / service message-delivery-method-web-push (push) Failing after 46s

This commit is contained in:
2024-12-12 13:30:28 +02:00
parent 2939b029b0
commit e07d748e87
16 changed files with 666 additions and 156 deletions
@@ -35,7 +35,11 @@ const dbsSecurity = {
},
tg_bot: {
admins: { roles: ["_admin"] },
members: { roles: [] },
members: { roles: ["_admin"] },
},
tg_bot_sessions: {
admins: { roles: ["_admin"] },
members: { roles: ["_admin"] },
},
};
+3
View File
@@ -28,6 +28,8 @@
"contributors": [],
"scripts": {
"build": "tsc",
"dev": "tsx src/bot/index.ts",
"dev:watch": "tsx --watch src/bot/index.ts",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist",
"lint": "eslint src",
@@ -69,6 +71,7 @@
"esbuild": "^0.24.0",
"esbuild-copy-static-files": "^0.1.0",
"eslint": "^9.16.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
+4 -8
View File
@@ -1,5 +1,6 @@
import { MyContext } from "../../types/myContext";
import { getQRCodesByUser } from "../../api/qrCodes";
import { MyContext } from "@/types/myContext";
import { getQRCodesByUser } from "@/api/qrCodes";
import { buildManageKeyboard } from "@/bot/utils/keyboards";
/**
* Обработка команды /manage с выводом подменю
@@ -23,12 +24,7 @@ export async function manageCommand(ctx: MyContext) {
}
// Генерация клавиатуры с QR-кодами
const keyboard = qrCodes.map((qr) => [
{
text: qr.name,
callback_data: `manage:${qr.uri}`,
},
]);
const keyboard = buildManageKeyboard(qrCodes);
await ctx.reply("Выберите QR-код для управления:", {
reply_markup: {
@@ -68,14 +68,17 @@ export async function newQRCodeConversation(
}
// 3. Выбор действий, доступных при сканировании
await ctx.reply("Выберите доступные действия:", {
reply_markup: {
inline_keyboard: buildActionsKeyboard([
{ action: "sendMessage", label: "Отправить сообщение" },
{ action: "viewContactInfo", label: "Посмотреть контактные данные" },
]),
await ctx.reply(
"Выберите одно или несколько действий, которые будут доступны при сканировании этого QR-кода:",
{
reply_markup: {
inline_keyboard: buildActionsKeyboard([
{ action: "sendMessage", label: "Отправить сообщение" },
{ action: "viewContactInfo", label: "Посмотреть контактные данные" },
]),
},
},
});
);
const actionsMsg = await conversation.waitFor("callback_query:data");
const chosenAction = actionsMsg.update.callback_query?.data;
+9 -3
View File
@@ -1,8 +1,10 @@
import { Bot, session } from "grammy";
import { conversations, createConversation } from "@grammyjs/conversations";
import { MyContext } from "../types/myContext"; // Ваш расширенный контекст
import { env } from "../config/env";
import { MyContext } from "@/types/myContext";
import { env } from "@/config/env";
import { CouchDBStorageAdapter } from "./storageAdapters/CouchDBStorageAdapter";
import { loadUserMiddleware } from "./middleware/loadUser";
import { tgBotSessionsDb } from "@/dbs";
import { setupCommands } from "./commands/setupCommands";
import { startCommand } from "./commands/start";
import { helpCommand } from "./commands/help";
@@ -11,11 +13,15 @@ import { manageCommand } from "./commands/manage";
import { callbackQueryHandler } from "./callbackHandlers";
import { newQRCodeConversation } from "./conversations/newQRCodeConversation";
const storage = new CouchDBStorageAdapter(tgBotSessionsDb, {
softDelete: true,
});
// Инициализируем бота с нужным типом контекста
const bot = new Bot<MyContext>(env.TELEGRAM_BOT_TOKEN);
// Подключаем сессии. По необходимости определите initial state для сессии
bot.use(session({ initial: () => ({}) }));
bot.use(session({ storage, initial: () => ({}) }));
// Подключаем conversations для многошаговых сценариев
bot.use(conversations());
@@ -0,0 +1,131 @@
import type { StorageAdapter } from "grammy";
export interface CouchDBStorageOptions {
/** Если true, при удалении сессии будет ставиться _deleted: true для поддержки репликаций по условиям полей */
softDelete?: boolean;
type?: string;
}
export interface CouchDBSessionDocument<T> {
_id: string;
_rev?: string;
type: string;
session?: T;
created_at: string;
updated_at: string;
_deleted?: boolean;
}
export class CouchDBStorageAdapter<T> implements StorageAdapter<T> {
constructor(
private db: PouchDB.Database<CouchDBSessionDocument<T>>,
private options: CouchDBStorageOptions = {
softDelete: false,
type: "tg_bot_session",
},
) {}
/**
* Чтение сессии по ключу.
* Возвращает данные сессии или undefined, если сессия не найдена.
*/
async read(key: string) {
try {
const id = this.getId(key);
const doc = await this.db.get(id);
return doc.session;
} catch (error: any) {
if (error.status !== 404) {
throw error;
}
}
}
/**
* Запись или обновление сессии по ключу.
* При конфликте повторяет попытку записи.
*/
async write(key: string, value: T) {
const _id = this.getId(key);
await this.retryOnConflict(async () => {
let doc: CouchDBSessionDocument<T>;
const updated_at = new Date().toISOString();
try {
doc = await this.db.get(_id);
doc.session = value;
doc.updated_at = updated_at;
} catch (error: any) {
if (error.status === 404) {
// Документ не найден, создаём новый
doc = {
_id,
type: "tg_bot_session",
session: value,
created_at: updated_at,
updated_at,
};
} else {
throw error;
}
}
await this.db.put(doc);
});
}
/**
* Удаление сессии по ключу без предварительного получения документа.
* Если включен softDelete, пытаемся сразу поставить _deleted: true.
* Если нет softDelete, просто пытаемся удалить без rev, что приведёт к ошибке 409,
* тогда при конфликте получим актуальный _rev и удалим.
*/
async delete(key: string) {
const id = this.getId(key);
await this.retryOnConflict(async () => {
let doc: CouchDBSessionDocument<T>;
try {
doc = await this.db.get(id);
} catch (error: any) {
if (error.status === 404) {
return; // Документ уже отсутствует
} else {
throw error;
}
}
if (this.options.softDelete) {
doc._deleted = true; // Помечаем документ как удалённый
await this.db.put(doc);
} else {
await this.db.remove(id, doc._rev!);
}
});
}
/**
* Вспомогательная функция для повторения операции при конфликте.
* Если при put/remove случится конфликт, повторим попытку до retries раз.
* fn принимает параметр attempt (номер попытки, начиная с 1).
*/
private async retryOnConflict(
fn: (attempt: number) => Promise<void>,
retries = 3,
): Promise<void> {
for (let i = 0; i < retries; i++) {
try {
await fn(i + 1);
return;
} catch (error: any) {
if (error.status === 409 && i < retries - 1) {
// Попытка ещё раз при конфликте
} else {
throw error;
}
}
}
}
private getId(key: string) {
return this.options.type ? `${this.options.type}:${key}` : key;
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ export function buildManageKeyboard(
return qrCodes.map((qr) => [
{
text: qr.name,
callback_data: `manage:${qr.uri}:delete`, // Пример - сразу кнопка на удаление. Можно сделать отдельное меню.
callback_data: `manage:${qr.uri}`,
},
]);
}
+10
View File
@@ -12,6 +12,8 @@ import type {
} from "@hereconnect/types";
import type { TelegramWebhookDesignDocument } from "@hereconnect/tg-bot-webhook-ddoc";
import { CouchDBSessionDocument } from "@/bot/storageAdapters/CouchDBStorageAdapter";
import type { SessionData } from "@/types/myContext";
// import type { TgUpdateDocument } from "./types";
if (!process.env.API_URL) {
@@ -60,6 +62,13 @@ export const tgBotDesignDocumentsDb =
tgBotDb as PouchDB.Database<TelegramWebhookDesignDocument>;
export const serviceSeqTrackerDb = tgBotDb as ServiceSeqTrackerDatabase;
export const tgBotSessionsDb = new PouchDB<CouchDBSessionDocument<SessionData>>(
`${process.env.API_URL}/tg_bot_sessions`,
{
skip_setup: true,
},
);
export const userChatSettingsDb = new PouchDB<UserChatSettingsDocument>(
`${process.env.API_URL}/user_chat_settings`,
{
@@ -84,6 +93,7 @@ export const checkDatabases = async () => {
serviceSeqTrackerDb,
userChatSettingsDb,
tgBotDb,
tgBotSessionsDb,
} as const).map(
async ([name, db]): Promise<
[string, Awaited<ReturnType<typeof db.info>>]
+26 -24
View File
@@ -1,7 +1,5 @@
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 { SvgProcessor } from "@/utils/images/svg/SvgProcessor";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
@@ -16,6 +14,7 @@ interface GenerateOptions {
logoColor?: string;
width?: number;
height?: number;
padding?: number;
}
const __filename = fileURLToPath(import.meta.url);
@@ -28,18 +27,21 @@ export async function generateQRCodeImage(
label = "Сообщите о проблеме или задайте вопрос",
color = "#000",
logoColor = "#000",
width = 1024,
height = 1024,
width = 800,
height = 800,
padding = 32,
}: GenerateOptions,
): Promise<Buffer> {
const optimizeOutput = format === "svg";
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 logoWithText = new SvgProcessor(
await fs.readFile(logoPath, "utf8"),
optimizeOutput,
)
.replaceFill(logoColor) // Заменяем цвет fill в лого
.addLabel("НаСвязи", logoColor, 0.01) // Добавляем лейбл "НаСвязи" в логотип
// .addPadding(padding) // Добавляем отступы вокруг логотипа
.toString();
const qrCode = new QRCodeStyling({
jsdom: JSDOM, // this is required
@@ -74,22 +76,22 @@ export async function generateQRCodeImage(
}
// Преобразуем SVG в строку
const svgText = rawData.toString("utf8");
let optimizedSvg = optimizeSvg(svgText);
if (!optimizedSvg) {
throw new Error("Ошибка оптимизации SVG");
}
let resultSvg = rawData.toString("utf8");
// Добавляем текст, если указано
if (label) {
optimizedSvg = addLabelToSvg(optimizedSvg, label, color);
}
resultSvg = new SvgProcessor(resultSvg, optimizeOutput)
.addLabel(label, color)
.addPadding(padding)
.toString();
if (format === "svg") {
if (!resultSvg) {
throw new Error("Ошибка оптимизации SVG");
}
// Если формат SVG, возвращаем буфер SVG
return Buffer.from(optimizedSvg, "utf8");
return Buffer.from(resultSvg, "utf8");
}
// Если формат PNG, преобразуем optimizedSvg в PNG через sharp
return sharp(Buffer.from(optimizedSvg, "utf8")).toFormat("png").toBuffer();
// Если формат PNG, преобразуем resultSvg в PNG через sharp
return sharp(Buffer.from(resultSvg, "utf8")).toFormat("png").toBuffer();
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { Context, SessionFlavor } from "grammy";
import { ConversationFlavor } from "@grammyjs/conversations";
import type { UserDocument } from "@hereconnect/types";
interface SessionData {
export interface SessionData {
step?: number;
}
@@ -0,0 +1,193 @@
import {
DOMParser,
XMLSerializer,
type Document as XmlDomDocument,
type Element as XmlElement,
} from "@xmldom/xmldom";
import { optimize } from "svgo";
import { calculateFontSizeForText } from "./calculateFontSizeForText";
export class SvgProcessor {
private doc: XmlDomDocument;
private svgElement: XmlElement;
constructor(
svgText: string,
private optimizeOutput: boolean = false,
) {
const parser = new DOMParser();
this.doc = parser.parseFromString(svgText, "image/svg+xml");
const svgElement = this.doc.getElementsByTagName("svg").item(0);
if (!svgElement) {
throw new Error("SVG не найден");
}
this.svgElement = svgElement;
}
addPadding(padding: number, backgroundColor = "transparent"): SvgProcessor {
const viewBox = this.svgElement
.getAttribute("viewBox")
?.split(" ")
.map(Number);
if (!viewBox) {
throw new Error("viewBox не найден");
}
const [x, y, width, height] = viewBox;
// Увеличиваем размеры viewBox с учётом отступов
const newWidth = width + padding * 2;
const newHeight = height + padding * 2;
const newViewBox = [x, y, newWidth, newHeight].join(" ");
this.svgElement.setAttribute("viewBox", newViewBox);
// Добавляем прямоугольник для фона, если требуется
if (backgroundColor !== "transparent") {
const rect = this.doc.createElementNS(
"http://www.w3.org/2000/svg",
"rect",
);
rect.setAttribute("x", x.toString());
rect.setAttribute("y", y.toString());
rect.setAttribute("width", newWidth.toString());
rect.setAttribute("height", newHeight.toString());
rect.setAttribute("fill", backgroundColor);
this.svgElement.insertBefore(rect, this.svgElement.firstChild);
}
// Сдвигаем все элементы
const elements = Array.from(this.svgElement.childNodes).filter(
(node) => node.nodeType === 1 && node !== this.svgElement.firstChild,
) as XmlElement[];
elements.forEach((el) => {
const isText = el.tagName === "text";
// Для текста добавляем дополнительное смещение
if (isText) {
const dy = el.getAttribute("dy") || "0";
if (!dy.includes("%")) {
el.setAttribute("dy", (parseFloat(dy) - padding).toString());
}
const dx = el.getAttribute("dx") || "0";
if (!dx.includes("%") && !el.getAttribute("x")?.includes("%")) {
el.setAttribute("dx", padding.toString());
}
} else {
const transform = el.getAttribute("transform") || "";
el.setAttribute(
"transform",
`translate(${padding}, ${padding}) ${transform}`.trim(),
);
}
});
// Удаляем атрибуты width и height, чтобы размеры SVG задавались только через viewBox
this.svgElement.removeAttribute("width");
this.svgElement.removeAttribute("height");
return this;
}
addLabel(
label: string,
color: string,
marginMultiplier: number = 0.1,
): SvgProcessor {
if (!label) {
return this;
}
const viewBoxAttr = this.svgElement.getAttribute("viewBox");
if (!viewBoxAttr) {
throw new Error("viewBox не найден");
}
const viewBox: readonly number[] = viewBoxAttr.split(" ").map(Number);
const [x, y, width, height] = viewBox;
if (viewBox.length === 4) {
// Рассчитываем размер шрифта
const fontSize = calculateFontSizeForText(label, width);
const textMargin = Math.min(fontSize, height * marginMultiplier);
const extraBottomPadding = fontSize * 0.3; // 30% дополнительного пространства для висячих символов
// Увеличиваем высоту viewBox
const newHeight = height + textMargin + fontSize;
this.svgElement.setAttribute(
"viewBox",
[x, y, width, newHeight].join(" "),
);
// Создаем элемент текста
const textElement = this.doc.createElementNS(
"http://www.w3.org/2000/svg",
"text",
);
textElement.setAttribute("x", "50%");
textElement.setAttribute("y", "100%");
textElement.setAttribute("dy", `-${extraBottomPadding}px`);
textElement.setAttribute("text-anchor", "middle");
textElement.setAttribute("alignment-baseline", "baseline");
textElement.setAttribute("dominant-baseline", "alphabetic");
textElement.setAttribute("fill", color);
textElement.setAttribute("font-size", fontSize.toString());
textElement.setAttribute("font-family", "Arial, sans-serif");
textElement.textContent = label;
this.svgElement.appendChild(textElement);
}
// Удаляем атрибуты width и height, чтобы размеры SVG задавались только через viewBox
this.svgElement.removeAttribute("width");
this.svgElement.removeAttribute("height");
return this;
}
replaceFill(fillColor: string): SvgProcessor {
const elements = this.doc.getElementsByTagName("*");
for (let i = 0; i < elements.length; i++) {
const el = elements.item(i);
if (el && el.hasAttribute("fill")) {
el.setAttribute("fill", fillColor);
}
}
return this;
}
/**
* Оптимизация SVG с использованием SVGO.
* @param svgText string изображения SVG.
* @returns Строка оптимизированного SVG.
*/
private 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;
}
toString(): string {
const serializer = new XMLSerializer();
const result = serializer.serializeToString(this.doc);
// console.log(result);
if (this.optimizeOutput) {
return this.optimizeSvg(result);
}
return result;
}
}
@@ -1,61 +0,0 @@
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);
}
@@ -1,25 +0,0 @@
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;
}
@@ -1,23 +0,0 @@
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);
}
+1 -2
View File
@@ -10,9 +10,8 @@
"skipLibCheck": true, // Пропуск проверки типов в библиотечных файлах
"declaration": true, // Генерация `.d.ts` файлов
"sourceMap": true, // Генерация `.map` файлов
"baseUrl": "./", // Базовая директория для путей
"paths": {
"*": ["src/*"] // Настройка алиасов (сопоставление путей)
"@/*": ["./src/*"] // Настройка алиасов (сопоставление путей)
}
},
"include": ["src"], // Указываем папку с исходниками