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
|
VITE_READ_BASE_URL=https://r.condev.ru
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
VITE_MANIFEST_NAME=НаСвязи-dev
|
VITE_MANIFEST_NAME=НаСвязи-dev
|
||||||
VITE_MANIFEST_SHORT_NAME=НаСвязи-dev
|
VITE_MANIFEST_SHORT_NAME=НаСвязи-dev
|
||||||
|
VITE_PROXY_API_URL=https://hereconnect.condev.ru/api
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
coverage
|
coverage
|
||||||
.env.local
|
.env.development.local
|
||||||
|
|
||||||
/cypress/videos/
|
/cypress/videos/
|
||||||
/cypress/screenshots/
|
/cypress/screenshots/
|
||||||
|
|||||||
@@ -12,12 +12,14 @@
|
|||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"type-check": "vue-tsc --build --force",
|
"type-check": "vue-tsc --build --force",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --write src/",
|
"format": "prettier --write src/",
|
||||||
"vite-bundle-visualizer": "npx vite-bundle-visualizer"
|
"vite-bundle-visualizer": "npx vite-bundle-visualizer"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@f3ve/vue-markdown-it": "^0.2.3",
|
"@f3ve/vue-markdown-it": "^0.2.3",
|
||||||
|
"@hereconnect/constants": "workspace:^",
|
||||||
"@hereconnect/types": "^1.0.0",
|
"@hereconnect/types": "^1.0.0",
|
||||||
"@primevue/forms": "^4.2.1",
|
"@primevue/forms": "^4.2.1",
|
||||||
"@primevue/themes": "^4.2.1",
|
"@primevue/themes": "^4.2.1",
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import type {
|
|||||||
import { DB_NAMES } from '@/constants/dbNames'
|
import { DB_NAMES } from '@/constants/dbNames'
|
||||||
|
|
||||||
export const getRemoteDb = <T extends BaseDocument>(dbName: DB_NAMES) =>
|
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)
|
export const getLocalDb = <T extends BaseDocument>(dbName: DB_NAMES) => new PouchDB<T>(dbName)
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import type { QRCodeDocument } from '@hereconnect/types'
|
import type { QRCodeDocument } from '@hereconnect/types'
|
||||||
|
import { EPOCH } from '@hereconnect/constants'
|
||||||
import { qrCodeDbLocal, qrCodeDbRemote } from '@/api/dbs'
|
import { qrCodeDbLocal, qrCodeDbRemote } from '@/api/dbs'
|
||||||
import { getUserUuid } from '@/utils/getUserUuid'
|
import { getUserUuid } from '@/utils/getUserUuid'
|
||||||
import { getBrowserUuid } from '@/utils/getBrowserUuid'
|
import { getBrowserUuid } from '@/utils/getBrowserUuid'
|
||||||
import { isNotFoundError } from '@/api/PouchDB'
|
import { isNotFoundError } from '@/api/PouchDB'
|
||||||
|
|
||||||
const EPOHE = 1731661630981
|
|
||||||
|
|
||||||
export const createQrCodeDocument = async (
|
export const createQrCodeDocument = async (
|
||||||
qrCodeDocPartial: Pick<
|
qrCodeDocPartial: Pick<
|
||||||
QRCodeDocument,
|
QRCodeDocument,
|
||||||
'name' | 'placement' | 'actions' | 'predefinedMessages' | 'messageDeliveryMethod'
|
'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(
|
const url = new URL(
|
||||||
`${import.meta.env.VITE_READ_BASE_URL || '/read'}/${uri}`,
|
`${import.meta.env.VITE_READ_BASE_URL || '/read'}/${uri}`,
|
||||||
location.origin,
|
location.origin,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { messagesDbLocal as db, messagesDbRemote as remoteDb } from '@/api/dbs'
|
||||||
import { type MessageDocument } from '@hereconnect/types'
|
import { type MessageDocument } from '@hereconnect/types'
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { qrCodePlacements } from '@/constants/qrCodePlacements'
|
import { qrCodePlacements } from '@hereconnect/constants'
|
||||||
import type { QRCodeDocument } from '@hereconnect/types'
|
import type { QRCodeDocument } from '@hereconnect/types'
|
||||||
const selectedPlacement = defineModel<null | QRCodeDocument['placement']>()
|
const selectedPlacement = defineModel<null | QRCodeDocument['placement']>()
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { QrCodePlacement } from '@/constants/qrCodePlacements'
|
import type { QrCodePlacement } from '@hereconnect/constants'
|
||||||
|
|
||||||
const props = defineProps<{ placement: QrCodePlacement }>()
|
const props = defineProps<{ placement: QrCodePlacement }>()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ref, watch } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
export const usePrefersColorSchemeStore = defineStore('prefersColorScheme', () => {
|
export const usePrefersColorSchemeStore = defineStore('prefersColorScheme', () => {
|
||||||
|
|||||||
@@ -175,5 +175,14 @@ export default defineConfig(({ mode }) => {
|
|||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': 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": {
|
"scripts": {
|
||||||
"start": "npm run start --workspace=apps/frontend",
|
"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": "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",
|
"build:deploy:services:db-migrations": "pnpm --filter ./services/db-migrations deploy --prod build/service-db-migrations",
|
||||||
"test": "npm run test --workspaces",
|
"test": "npm run test --workspaces",
|
||||||
"lint": "npm run lint --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": "^9.16.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"fast-check": "^3.23.1",
|
"prettier": "^3.4.1",
|
||||||
"jest": "^29.7.0",
|
"typescript": "^5.7.2"
|
||||||
"prettier": "^3.4.2",
|
|
||||||
"ts-jest": "^29.2.5",
|
|
||||||
"typescript": "^5.7.2",
|
|
||||||
"vitest": "^2.1.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export class ServiceSeqTracker {
|
||||||
/**
|
/**
|
||||||
* @param _id
|
* @param _id
|
||||||
|
* @param serviceSeqTrackerDb
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
static async Init(_id: string) {
|
static async Init(
|
||||||
|
_id: string,
|
||||||
|
serviceSeqTrackerDb: ServiceSeqTrackerDatabase,
|
||||||
|
) {
|
||||||
const doc = await serviceSeqTrackerDb.get(_id).catch((error) => {
|
const doc = await serviceSeqTrackerDb.get(_id).catch((error) => {
|
||||||
if (error.name === "not_found") {
|
if (error.name === "not_found") {
|
||||||
return {
|
return {
|
||||||
@@ -15,10 +26,13 @@ export class ServiceSeqTracker {
|
|||||||
}
|
}
|
||||||
throw error; // Re-throw other errors
|
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() {
|
get lastSeq() {
|
||||||
return this.doc.lastSeq;
|
return this.doc.lastSeq;
|
||||||
@@ -38,6 +52,6 @@ export class ServiceSeqTracker {
|
|||||||
|
|
||||||
saveSeq(seq: number | string) {
|
saveSeq(seq: number | string) {
|
||||||
this.doc.lastSeq = seq;
|
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",
|
"name": "@hereconnect/types",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "src/index.ts",
|
"main": "dist/index.js",
|
||||||
"types": "src/index.ts",
|
"types": "dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"import": "./src/index.ts",
|
"import": "./dist/index.d.js",
|
||||||
"default": "./src/index.ts"
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": ["src/**/*"],
|
"files": ["dist/**/*"],
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "tsc --noEmit src/index.ts",
|
"lint": "tsc --noEmit src/index.ts",
|
||||||
"build": "pnpm run lint"
|
"build": "tsc",
|
||||||
|
"prepare": "npm run build"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "~5.6.3",
|
"typescript": "~5.6.3",
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ export interface QRCodeDocument extends BaseDocument {
|
|||||||
user_uuid: string;
|
user_uuid: string;
|
||||||
|
|
||||||
/** Идентификатор браузера, через который был создан QR-код */
|
/** Идентификатор браузера, через который был создан QR-код */
|
||||||
browser_uuid: string;
|
browser_uuid?: string;
|
||||||
|
|
||||||
|
/** Идентификатор телеграм-аккаунта, через который был создан QR-код */
|
||||||
|
telegram_id?: string;
|
||||||
|
|
||||||
/** Название объекта, для которого создан QR‑код, например "Мой автомобиль" */
|
/** Название объекта, для которого создан QR‑код, например "Мой автомобиль" */
|
||||||
name: string;
|
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 {
|
export interface UserDocument extends BaseDocument {
|
||||||
/** Тип документа, всегда 'user' */
|
/** Тип документа, всегда 'user' */
|
||||||
type: 'user'
|
type: "user";
|
||||||
|
|
||||||
|
/** Telegram ID пользователя */
|
||||||
|
telegram_id?: number;
|
||||||
|
|
||||||
/** Имя пользователя */
|
/** Имя пользователя */
|
||||||
name: string
|
name: string;
|
||||||
|
|
||||||
/** Уникальный UUID пользователя, не связанный с _id */
|
/** Уникальный UUID пользователя, не связанный с _id */
|
||||||
user_uuid: string
|
user_uuid: string;
|
||||||
|
|
||||||
/** Email пользователя */
|
/** Email пользователя */
|
||||||
email: string
|
email?: string;
|
||||||
|
|
||||||
/** Роли пользователя (например, ['admin', 'user']) */
|
/** Роли пользователя (например, ['admin', 'user']) */
|
||||||
roles: string[]
|
roles: string[];
|
||||||
|
|
||||||
/** Пароль пользователя (должен храниться в зашифрованном виде) */
|
/** Пароль пользователя (должен храниться в зашифрованном виде) */
|
||||||
password: string
|
password: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"emitDeclarationOnly": true,
|
|
||||||
"outDir": "./dist",
|
"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"] },
|
admins: { roles: ["_admin"] },
|
||||||
members: { roles: [] },
|
members: { roles: [] },
|
||||||
},
|
},
|
||||||
|
tg_bot: {
|
||||||
|
admins: { roles: ["_admin"] },
|
||||||
|
members: { roles: [] },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!process.env.API_URL) {
|
if (!process.env.API_URL) {
|
||||||
@@ -94,7 +98,7 @@ export const down = async () => {
|
|||||||
if (responseDeleteDb.ok) {
|
if (responseDeleteDb.ok) {
|
||||||
console.log(`deleted ${dbName}`);
|
console.log(`deleted ${dbName}`);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
console.error(
|
||||||
`${dbName} delete error ${JSON.stringify(responseDeleteDb)}`,
|
`${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 \
|
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 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",
|
"name": "@hereconnect/service-message-delivery-method-web-push",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.ts",
|
"main": "dist/service.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "@hereconnect/license",
|
"license": "@hereconnect/license",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"contributors": [],
|
"contributors": [],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/service.js",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"generate-keys": "node src/generate-keys.ts",
|
"generate-keys": "node src/generate-keys.ts",
|
||||||
@@ -36,6 +36,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hereconnect/couchdb-changes-stream": "workspace:^",
|
"@hereconnect/couchdb-changes-stream": "workspace:^",
|
||||||
|
"@hereconnect/lib-service-health-checkserver": "workspace:^",
|
||||||
|
"@hereconnect/lib-service-seq-tracker": "workspace:^",
|
||||||
"pouchdb-adapter-http": "^9.0.0",
|
"pouchdb-adapter-http": "^9.0.0",
|
||||||
"pouchdb-core": "^9.0.0",
|
"pouchdb-core": "^9.0.0",
|
||||||
"pouchdb-find": "^9.0.0",
|
"pouchdb-find": "^9.0.0",
|
||||||
|
|||||||
+5
-2
@@ -10,11 +10,12 @@ import {
|
|||||||
webPushSubscriptionsDb,
|
webPushSubscriptionsDb,
|
||||||
userChatSettingsDb,
|
userChatSettingsDb,
|
||||||
checkDatabases,
|
checkDatabases,
|
||||||
|
serviceSeqTrackerDb,
|
||||||
} from "./dbs";
|
} from "./dbs";
|
||||||
import { setupVapidDetails } from "./setupVapidDetails";
|
import { setupVapidDetails } from "./setupVapidDetails";
|
||||||
import type { MessageDocument } from "@hereconnect/types";
|
import type { MessageDocument } from "@hereconnect/types";
|
||||||
import { ServiceSeqTracker } from "./serviceSeqTracker";
|
import { ServiceSeqTracker } from "@hereconnect/lib-service-seq-tracker";
|
||||||
import { startHealthServer } from "./health-server";
|
import { startHealthServer } from "@hereconnect/lib-service-health-checkserver";
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ const start = async () => {
|
|||||||
|
|
||||||
const serviceSeqTracker = await ServiceSeqTracker.Init(
|
const serviceSeqTracker = await ServiceSeqTracker.Init(
|
||||||
"_local/service:message-delivery-method-web-push",
|
"_local/service:message-delivery-method-web-push",
|
||||||
|
serviceSeqTrackerDb,
|
||||||
);
|
);
|
||||||
|
|
||||||
const changesStream = new CouchDBChangesStream<MessageDocument>(
|
const changesStream = new CouchDBChangesStream<MessageDocument>(
|
||||||
@@ -56,6 +58,7 @@ const start = async () => {
|
|||||||
// Gracefully handle SIGINT
|
// Gracefully handle SIGINT
|
||||||
process.on("SIGINT", async () => {
|
process.on("SIGINT", async () => {
|
||||||
changesStream?.stop();
|
changesStream?.stop();
|
||||||
|
setTimeout(() => process.exit(1), 5_000).unref();
|
||||||
});
|
});
|
||||||
|
|
||||||
console.info("Service started from sequence", serviceSeqTracker.lastSeq);
|
console.info("Service started from sequence", serviceSeqTracker.lastSeq);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"addExtension": "js"
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ESNext", // Указываем современный стандарт
|
||||||
"module": "ESNext",
|
"module": "ESNext", // Используем ES модули
|
||||||
"outDir": "./dist",
|
"moduleResolution": "node", // Разрешение модулей как в Node.js
|
||||||
"rootDir": "./src",
|
"strict": true, // Включаем строгий режим
|
||||||
"strict": true,
|
"outDir": "./dist", // Папка для вывода скомпилированных файлов
|
||||||
"moduleResolution": "node",
|
"rootDir": "./src", // Корневая папка исходников
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true, // Совместимость с CommonJS модулями
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true, // Пропуск проверки типов в библиотечных файлах
|
||||||
"types": ["node"]
|
"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