create qr-codes by tg bot
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
||||
VITE_API_URL=https://hereconnect.condev.ru/api
|
||||
VITE_API_URL=/api
|
||||
VITE_READ_BASE_URL=https://r.condev.ru
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
VITE_MANIFEST_NAME=НаСвязи-dev
|
||||
VITE_MANIFEST_SHORT_NAME=НаСвязи-dev
|
||||
VITE_PROXY_API_URL=https://hereconnect.condev.ru/api
|
||||
|
||||
@@ -12,7 +12,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
.env.local
|
||||
.env.development.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
"test:e2e": "playwright test",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build --force",
|
||||
"lint": "eslint . --fix",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write src/",
|
||||
"vite-bundle-visualizer": "npx vite-bundle-visualizer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@f3ve/vue-markdown-it": "^0.2.3",
|
||||
"@hereconnect/constants": "workspace:^",
|
||||
"@hereconnect/types": "^1.0.0",
|
||||
"@primevue/forms": "^4.2.1",
|
||||
"@primevue/themes": "^4.2.1",
|
||||
|
||||
@@ -12,7 +12,9 @@ import type {
|
||||
import { DB_NAMES } from '@/constants/dbNames'
|
||||
|
||||
export const getRemoteDb = <T extends BaseDocument>(dbName: DB_NAMES) =>
|
||||
new PouchDB<T>(`${import.meta.env.VITE_API_URL}/${dbName}`, { skip_setup: true })
|
||||
new PouchDB<T>(new URL(`${import.meta.env.VITE_API_URL}/${dbName}`, location.origin).toString(), {
|
||||
skip_setup: true,
|
||||
})
|
||||
|
||||
export const getLocalDb = <T extends BaseDocument>(dbName: DB_NAMES) => new PouchDB<T>(dbName)
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import type { QRCodeDocument } from '@hereconnect/types'
|
||||
import { EPOCH } from '@hereconnect/constants'
|
||||
import { qrCodeDbLocal, qrCodeDbRemote } from '@/api/dbs'
|
||||
import { getUserUuid } from '@/utils/getUserUuid'
|
||||
import { getBrowserUuid } from '@/utils/getBrowserUuid'
|
||||
import { isNotFoundError } from '@/api/PouchDB'
|
||||
|
||||
const EPOHE = 1731661630981
|
||||
|
||||
export const createQrCodeDocument = async (
|
||||
qrCodeDocPartial: Pick<
|
||||
QRCodeDocument,
|
||||
'name' | 'placement' | 'actions' | 'predefinedMessages' | 'messageDeliveryMethod'
|
||||
>,
|
||||
) => {
|
||||
const uri = Math.abs(Date.now() - EPOHE).toString(36)
|
||||
const uri = Math.abs(Date.now() - EPOCH).toString(36)
|
||||
const url = new URL(
|
||||
`${import.meta.env.VITE_READ_BASE_URL || '/read'}/${uri}`,
|
||||
location.origin,
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, useTemplateRef, watch, nextTick } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { messagesDbLocal as db, messagesDbRemote as remoteDb } from '@/api/dbs'
|
||||
import { type MessageDocument } from '@hereconnect/types'
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { qrCodePlacements } from '@/constants/qrCodePlacements'
|
||||
import { qrCodePlacements } from '@hereconnect/constants'
|
||||
import type { QRCodeDocument } from '@hereconnect/types'
|
||||
const selectedPlacement = defineModel<null | QRCodeDocument['placement']>()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { QrCodePlacement } from '@/constants/qrCodePlacements'
|
||||
import type { QrCodePlacement } from '@hereconnect/constants'
|
||||
|
||||
const props = defineProps<{ placement: QrCodePlacement }>()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const usePrefersColorSchemeStore = defineStore('prefersColorScheme', () => {
|
||||
|
||||
@@ -175,5 +175,14 @@ export default defineConfig(({ mode }) => {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_PROXY_API_URL,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
"scripts": {
|
||||
"start": "npm run start --workspace=apps/frontend",
|
||||
"build": "npm run build:deploy:services:message-delivery-method-web-push && npm run build:deploy:services:db-migrations",
|
||||
"build:deploy:services:message-delivery-method-web-push": "pnpm --filter ./services/message-delivery-method-web-push deploy --prod build/service-message-delivery-method-web-push",
|
||||
"build:deploy:services:message-delivery-method-web-push": "npm -w ./services/message-delivery-method-web-push run build && pnpm --filter ./services/message-delivery-method-web-push deploy --prod build/service-message-delivery-method-web-push",
|
||||
"build:deploy:services:db-migrations": "pnpm --filter ./services/db-migrations deploy --prod build/service-db-migrations",
|
||||
"test": "npm run test --workspaces",
|
||||
"lint": "npm run lint --workspaces"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dist
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@hereconnect/constants",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist/**/*"],
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "tsc --noEmit src/index.ts",
|
||||
"build": "tsc",
|
||||
"prepare": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hereconnect/types": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "~5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const EPOCH = 1731661630981;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./qrCodePlacements";
|
||||
export * from "./EPOCH";
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"types": [],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/index.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -42,11 +42,7 @@
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"fast-check": "^3.23.1",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dist
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@hereconnect/lib-service-health-checkserver",
|
||||
"version": "1.0.0",
|
||||
"main": "src/health-server.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/health-server.ts",
|
||||
"default": "./src/health-server.ts"
|
||||
}
|
||||
},
|
||||
"files": ["src/**/*"],
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "tsc --noEmit src/health-server.ts",
|
||||
"build": "pnpm run lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.6",
|
||||
"typescript": "~5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["index.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
dist
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@hereconnect/lib-service-seq-tracker",
|
||||
"version": "1.0.0",
|
||||
"main": "src/serviceSeqTracker.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/serviceSeqTracker.ts",
|
||||
"default": "./src/serviceSeqTracker.ts"
|
||||
}
|
||||
},
|
||||
"files": ["src/**/*"],
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "tsc --noEmit src/serviceSeqTracker.ts",
|
||||
"build": "pnpm run lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.6.3",
|
||||
"@types/node": "^20.17.6",
|
||||
"@types/pouchdb-core": "^7.0.15"
|
||||
}
|
||||
}
|
||||
+19
-5
@@ -1,11 +1,22 @@
|
||||
import { serviceSeqTrackerDb, type ServiceSeqTrackerDocument } from "./dbs";
|
||||
import { type defaults as PouchDB } from "pouchdb-core";
|
||||
|
||||
export interface ServiceSeqTrackerDocument {
|
||||
lastSeq: string | number;
|
||||
}
|
||||
|
||||
export type ServiceSeqTrackerDatabase =
|
||||
PouchDB.Database<ServiceSeqTrackerDocument>;
|
||||
|
||||
export class ServiceSeqTracker {
|
||||
/**
|
||||
* @param _id
|
||||
* @param serviceSeqTrackerDb
|
||||
* @constructor
|
||||
*/
|
||||
static async Init(_id: string) {
|
||||
static async Init(
|
||||
_id: string,
|
||||
serviceSeqTrackerDb: ServiceSeqTrackerDatabase,
|
||||
) {
|
||||
const doc = await serviceSeqTrackerDb.get(_id).catch((error) => {
|
||||
if (error.name === "not_found") {
|
||||
return {
|
||||
@@ -15,10 +26,13 @@ export class ServiceSeqTracker {
|
||||
}
|
||||
throw error; // Re-throw other errors
|
||||
});
|
||||
return new ServiceSeqTracker(doc);
|
||||
return new ServiceSeqTracker(doc, serviceSeqTrackerDb);
|
||||
}
|
||||
|
||||
private constructor(private doc: ServiceSeqTrackerDocument) {}
|
||||
private constructor(
|
||||
private doc: ServiceSeqTrackerDocument,
|
||||
private serviceSeqTrackerDb: ServiceSeqTrackerDatabase,
|
||||
) {}
|
||||
|
||||
get lastSeq() {
|
||||
return this.doc.lastSeq;
|
||||
@@ -38,6 +52,6 @@ export class ServiceSeqTracker {
|
||||
|
||||
saveSeq(seq: number | string) {
|
||||
this.doc.lastSeq = seq;
|
||||
return serviceSeqTrackerDb.put(this.doc);
|
||||
return this.serviceSeqTrackerDb.put(this.doc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["index.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./updates/saveUpdate";
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
__exportStar(require("./updates/saveUpdate"), exports);
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,uDAAqC"}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import type { BaseDocument } from "@hereconnect/types";
|
||||
export interface TelegramWebhookDesignDocument extends BaseDocument {
|
||||
_id: "_design/webhook";
|
||||
type: "_design/webhook";
|
||||
updated_at: BaseDocument["created_at"];
|
||||
updates: {
|
||||
saveUpdate: string;
|
||||
};
|
||||
constants: {
|
||||
telegram_secret_token: string;
|
||||
telegram_webhook_url?: string;
|
||||
};
|
||||
}
|
||||
export interface TgUpdateDocument extends BaseDocument {
|
||||
data: unknown;
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
//# sourceMappingURL=types.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { TelegramWebhookDesignDocument, TgUpdateDocument } from "../types";
|
||||
export type { TelegramWebhookDesignDocument };
|
||||
type CouchRequest = {
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
method: string;
|
||||
query: Record<string, string>;
|
||||
userCtx: {
|
||||
name: string | null;
|
||||
roles: string[];
|
||||
};
|
||||
};
|
||||
type CouchResponse = [
|
||||
TgUpdateDocument | null,
|
||||
{
|
||||
code: number;
|
||||
body: string;
|
||||
} | string
|
||||
];
|
||||
type UpdateFunction = (doc: TgUpdateDocument | null, req: CouchRequest) => CouchResponse;
|
||||
export declare const saveUpdate: UpdateFunction;
|
||||
export declare const saveUpdateString: string;
|
||||
@@ -0,0 +1,23 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.saveUpdateString = exports.saveUpdate = void 0;
|
||||
var saveUpdate = function (doc, req) {
|
||||
var token = req.headers["X-Telegram-Bot-Api-Secret-Token"];
|
||||
if (token !== this.constants.telegram_secret_token) {
|
||||
return [null, { code: 403, body: "Forbidden: Invalid token" }];
|
||||
}
|
||||
var data = JSON.parse(req.body);
|
||||
if (doc) {
|
||||
return [null, { code: 304, body: "Update already exists" }];
|
||||
}
|
||||
doc = {
|
||||
_id: "tg_update:" + data.update_id.toString(),
|
||||
type: "tg_update",
|
||||
data: data,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
return [doc, "Message saved"];
|
||||
};
|
||||
exports.saveUpdate = saveUpdate;
|
||||
exports.saveUpdateString = exports.saveUpdate.toString();
|
||||
//# sourceMappingURL=saveUpdate.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"saveUpdate.js","sourceRoot":"","sources":["../../src/updates/saveUpdate.ts"],"names":[],"mappings":";;;AAyBO,IAAM,UAAU,GAAmB,UAExC,GAA4B,EAC5B,GAAiB;IAEjB,IAAM,KAAK,GAAG,GAAG,CAAC,OAAO,CAAC,iCAAiC,CAAC,CAAC;IAC7D,IAAI,KAAK,KAAK,IAAI,CAAC,SAAS,CAAC,qBAAqB,EAAE,CAAC;QACnD,OAAO,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,0BAA0B,EAAE,CAAC,CAAC;IACjE,CAAC;IAED,IAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAElC,IAAI,GAAG,EAAE,CAAC;QACR,OAAO,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,uBAAuB,EAAE,CAAC,CAAC;IAC9D,CAAC;IAED,GAAG,GAAG;QACJ,GAAG,EAAE,YAAY,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE;QAC7C,IAAI,EAAE,WAAW;QACjB,IAAI,EAAE,IAAI;QACV,UAAU,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;KACrC,CAAC;IAEF,OAAO,CAAC,GAAG,EAAE,eAAe,CAAC,CAAC;AAChC,CAAC,CAAC;AAxBW,QAAA,UAAU,cAwBrB;AAEW,QAAA,gBAAgB,GAAG,kBAAU,CAAC,QAAQ,EAAE,CAAC"}
|
||||
@@ -0,0 +1,25 @@
|
||||
// eslint.config.mjs
|
||||
import typescriptPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import parser from "@typescript-eslint/parser";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist", "node_modules"], // Исключаем папки из проверки
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.ts"], // Проверяем только TypeScript файлы
|
||||
languageOptions: {
|
||||
parser, // Используем парсер TypeScript
|
||||
ecmaVersion: "latest", // Последняя версия ECMAScript
|
||||
sourceType: "module", // Модули ES
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptPlugin, // Используем плагин TypeScript
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": ["error"], // Ошибка для неиспользуемых переменных
|
||||
semi: ["error", "always"], // Всегда требовать точку с запятой
|
||||
quotes: ["error", "double"], // Использовать двойные кавычки
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@hereconnect/tg-bot-webhook-ddoc",
|
||||
"version": "1.0.1",
|
||||
"license": "@hereconnect/license",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=v20.10.0",
|
||||
"pnpm": ">=8.15.0"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist/**/*"],
|
||||
"devDependencies": {
|
||||
"@hereconnect/types": "workspace:^",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||
"@typescript-eslint/parser": "^8.18.0",
|
||||
"eslint": "^9.16.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./updates/saveUpdate";
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { BaseDocument } from "@hereconnect/types";
|
||||
|
||||
export interface TelegramWebhookDesignDocument extends BaseDocument {
|
||||
_id: "_design/webhook";
|
||||
type: "_design/webhook";
|
||||
updated_at: BaseDocument["created_at"];
|
||||
|
||||
updates: {
|
||||
saveUpdate: string;
|
||||
};
|
||||
constants: {
|
||||
telegram_secret_token: string;
|
||||
telegram_webhook_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TgUpdateDocument extends BaseDocument {
|
||||
data: unknown;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { TelegramWebhookDesignDocument, TgUpdateDocument } from "../types";
|
||||
|
||||
export type { TelegramWebhookDesignDocument };
|
||||
|
||||
type CouchRequest = {
|
||||
body: string; // JSON-строка
|
||||
headers: Record<string, string>; // Заголовки запроса
|
||||
method: string; // HTTP-метод (обычно POST для update handlers)
|
||||
query: Record<string, string>; // Параметры запроса (например, ?key=value)
|
||||
userCtx: {
|
||||
name: string | null; // Имя пользователя, если используется аутентификация
|
||||
roles: string[]; // Роли пользователя
|
||||
};
|
||||
};
|
||||
|
||||
type CouchResponse = [
|
||||
TgUpdateDocument | null,
|
||||
{ code: number; body: string } | string,
|
||||
];
|
||||
|
||||
type UpdateFunction = (
|
||||
doc: TgUpdateDocument | null,
|
||||
req: CouchRequest,
|
||||
) => CouchResponse;
|
||||
|
||||
export const saveUpdate: UpdateFunction = function (
|
||||
this: TelegramWebhookDesignDocument,
|
||||
doc: TgUpdateDocument | null,
|
||||
req: CouchRequest,
|
||||
): CouchResponse {
|
||||
const token = req.headers["X-Telegram-Bot-Api-Secret-Token"];
|
||||
if (token !== this.constants.telegram_secret_token) {
|
||||
return [null, { code: 403, body: "Forbidden: Invalid token" }];
|
||||
}
|
||||
|
||||
const data = JSON.parse(req.body);
|
||||
|
||||
if (doc) {
|
||||
return [null, { code: 304, body: "Update already exists" }];
|
||||
}
|
||||
|
||||
doc = {
|
||||
_id: "tg_update:" + data.update_id.toString(),
|
||||
type: "tg_update",
|
||||
data: data,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return [doc, "Message saved"];
|
||||
};
|
||||
|
||||
export const saveUpdateString = saveUpdate.toString();
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES5",
|
||||
"module": "CommonJS",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"sourceMap": true,
|
||||
"declaration": true
|
||||
},
|
||||
"files": ["src/index.ts"],
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
{
|
||||
"name": "@hereconnect/types",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
"import": "./dist/index.d.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["src/**/*"],
|
||||
"files": ["dist/**/*"],
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "tsc --noEmit src/index.ts",
|
||||
"build": "pnpm run lint"
|
||||
"build": "tsc",
|
||||
"prepare": "npm run build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "~5.6.3",
|
||||
|
||||
@@ -27,7 +27,10 @@ export interface QRCodeDocument extends BaseDocument {
|
||||
user_uuid: string;
|
||||
|
||||
/** Идентификатор браузера, через который был создан QR-код */
|
||||
browser_uuid: string;
|
||||
browser_uuid?: string;
|
||||
|
||||
/** Идентификатор телеграм-аккаунта, через который был создан QR-код */
|
||||
telegram_id?: string;
|
||||
|
||||
/** Название объекта, для которого создан QR‑код, например "Мой автомобиль" */
|
||||
name: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BaseDocument } from './BaseDocument'
|
||||
import type { BaseDocument } from "./BaseDocument";
|
||||
|
||||
/**
|
||||
* Интерфейс для документа пользователя.
|
||||
@@ -6,20 +6,23 @@ import type { BaseDocument } from './BaseDocument'
|
||||
*/
|
||||
export interface UserDocument extends BaseDocument {
|
||||
/** Тип документа, всегда 'user' */
|
||||
type: 'user'
|
||||
type: "user";
|
||||
|
||||
/** Telegram ID пользователя */
|
||||
telegram_id?: number;
|
||||
|
||||
/** Имя пользователя */
|
||||
name: string
|
||||
name: string;
|
||||
|
||||
/** Уникальный UUID пользователя, не связанный с _id */
|
||||
user_uuid: string
|
||||
user_uuid: string;
|
||||
|
||||
/** Email пользователя */
|
||||
email: string
|
||||
email?: string;
|
||||
|
||||
/** Роли пользователя (например, ['admin', 'user']) */
|
||||
roles: string[]
|
||||
roles: string[];
|
||||
|
||||
/** Пароль пользователя (должен храниться в зашифрованном виде) */
|
||||
password: string
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist",
|
||||
"strict": true
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["web-push"],
|
||||
},
|
||||
"include": ["index.ts"]
|
||||
"include": ["src/index.ts"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
}
|
||||
Generated
+1285
-1852
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
+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);
|
||||
@@ -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