diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfdb2dc..e0d20ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,6 +425,9 @@ importers: eslint: specifier: ^9.16.0 version: 9.16.0 + tsx: + specifier: ^4.19.2 + version: 4.19.2 typescript: specifier: ^5.7.2 version: 5.7.2 @@ -771,6 +774,15 @@ packages: dev: true optional: true + /@esbuild/aix-ppc64@0.23.1: + resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true + /@esbuild/aix-ppc64@0.24.0: resolution: {integrity: sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==} engines: {node: '>=18'} @@ -789,6 +801,15 @@ packages: dev: true optional: true + /@esbuild/android-arm64@0.23.1: + resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-arm64@0.24.0: resolution: {integrity: sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==} engines: {node: '>=18'} @@ -807,6 +828,15 @@ packages: dev: true optional: true + /@esbuild/android-arm@0.23.1: + resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-arm@0.24.0: resolution: {integrity: sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==} engines: {node: '>=18'} @@ -825,6 +855,15 @@ packages: dev: true optional: true + /@esbuild/android-x64@0.23.1: + resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-x64@0.24.0: resolution: {integrity: sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==} engines: {node: '>=18'} @@ -843,6 +882,15 @@ packages: dev: true optional: true + /@esbuild/darwin-arm64@0.23.1: + resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + /@esbuild/darwin-arm64@0.24.0: resolution: {integrity: sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==} engines: {node: '>=18'} @@ -861,6 +909,15 @@ packages: dev: true optional: true + /@esbuild/darwin-x64@0.23.1: + resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + /@esbuild/darwin-x64@0.24.0: resolution: {integrity: sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==} engines: {node: '>=18'} @@ -879,6 +936,15 @@ packages: dev: true optional: true + /@esbuild/freebsd-arm64@0.23.1: + resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/freebsd-arm64@0.24.0: resolution: {integrity: sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==} engines: {node: '>=18'} @@ -897,6 +963,15 @@ packages: dev: true optional: true + /@esbuild/freebsd-x64@0.23.1: + resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/freebsd-x64@0.24.0: resolution: {integrity: sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==} engines: {node: '>=18'} @@ -915,6 +990,15 @@ packages: dev: true optional: true + /@esbuild/linux-arm64@0.23.1: + resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-arm64@0.24.0: resolution: {integrity: sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==} engines: {node: '>=18'} @@ -933,6 +1017,15 @@ packages: dev: true optional: true + /@esbuild/linux-arm@0.23.1: + resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-arm@0.24.0: resolution: {integrity: sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==} engines: {node: '>=18'} @@ -951,6 +1044,15 @@ packages: dev: true optional: true + /@esbuild/linux-ia32@0.23.1: + resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-ia32@0.24.0: resolution: {integrity: sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==} engines: {node: '>=18'} @@ -969,6 +1071,15 @@ packages: dev: true optional: true + /@esbuild/linux-loong64@0.23.1: + resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-loong64@0.24.0: resolution: {integrity: sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==} engines: {node: '>=18'} @@ -987,6 +1098,15 @@ packages: dev: true optional: true + /@esbuild/linux-mips64el@0.23.1: + resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-mips64el@0.24.0: resolution: {integrity: sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==} engines: {node: '>=18'} @@ -1005,6 +1125,15 @@ packages: dev: true optional: true + /@esbuild/linux-ppc64@0.23.1: + resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-ppc64@0.24.0: resolution: {integrity: sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==} engines: {node: '>=18'} @@ -1023,6 +1152,15 @@ packages: dev: true optional: true + /@esbuild/linux-riscv64@0.23.1: + resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-riscv64@0.24.0: resolution: {integrity: sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==} engines: {node: '>=18'} @@ -1041,6 +1179,15 @@ packages: dev: true optional: true + /@esbuild/linux-s390x@0.23.1: + resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-s390x@0.24.0: resolution: {integrity: sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==} engines: {node: '>=18'} @@ -1059,6 +1206,15 @@ packages: dev: true optional: true + /@esbuild/linux-x64@0.23.1: + resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-x64@0.24.0: resolution: {integrity: sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==} engines: {node: '>=18'} @@ -1077,6 +1233,15 @@ packages: dev: true optional: true + /@esbuild/netbsd-x64@0.23.1: + resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/netbsd-x64@0.24.0: resolution: {integrity: sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==} engines: {node: '>=18'} @@ -1086,6 +1251,15 @@ packages: dev: true optional: true + /@esbuild/openbsd-arm64@0.23.1: + resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/openbsd-arm64@0.24.0: resolution: {integrity: sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==} engines: {node: '>=18'} @@ -1104,6 +1278,15 @@ packages: dev: true optional: true + /@esbuild/openbsd-x64@0.23.1: + resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/openbsd-x64@0.24.0: resolution: {integrity: sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==} engines: {node: '>=18'} @@ -1122,6 +1305,15 @@ packages: dev: true optional: true + /@esbuild/sunos-x64@0.23.1: + resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + /@esbuild/sunos-x64@0.24.0: resolution: {integrity: sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==} engines: {node: '>=18'} @@ -1140,6 +1332,15 @@ packages: dev: true optional: true + /@esbuild/win32-arm64@0.23.1: + resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-arm64@0.24.0: resolution: {integrity: sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==} engines: {node: '>=18'} @@ -1158,6 +1359,15 @@ packages: dev: true optional: true + /@esbuild/win32-ia32@0.23.1: + resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-ia32@0.24.0: resolution: {integrity: sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==} engines: {node: '>=18'} @@ -1176,6 +1386,15 @@ packages: dev: true optional: true + /@esbuild/win32-x64@0.23.1: + resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-x64@0.24.0: resolution: {integrity: sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==} engines: {node: '>=18'} @@ -3430,6 +3649,38 @@ packages: '@esbuild/win32-x64': 0.21.5 dev: true + /esbuild@0.23.1: + resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==} + engines: {node: '>=18'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.23.1 + '@esbuild/android-arm': 0.23.1 + '@esbuild/android-arm64': 0.23.1 + '@esbuild/android-x64': 0.23.1 + '@esbuild/darwin-arm64': 0.23.1 + '@esbuild/darwin-x64': 0.23.1 + '@esbuild/freebsd-arm64': 0.23.1 + '@esbuild/freebsd-x64': 0.23.1 + '@esbuild/linux-arm': 0.23.1 + '@esbuild/linux-arm64': 0.23.1 + '@esbuild/linux-ia32': 0.23.1 + '@esbuild/linux-loong64': 0.23.1 + '@esbuild/linux-mips64el': 0.23.1 + '@esbuild/linux-ppc64': 0.23.1 + '@esbuild/linux-riscv64': 0.23.1 + '@esbuild/linux-s390x': 0.23.1 + '@esbuild/linux-x64': 0.23.1 + '@esbuild/netbsd-x64': 0.23.1 + '@esbuild/openbsd-arm64': 0.23.1 + '@esbuild/openbsd-x64': 0.23.1 + '@esbuild/sunos-x64': 0.23.1 + '@esbuild/win32-arm64': 0.23.1 + '@esbuild/win32-ia32': 0.23.1 + '@esbuild/win32-x64': 0.23.1 + dev: true + /esbuild@0.24.0: resolution: {integrity: sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==} engines: {node: '>=18'} @@ -3982,6 +4233,12 @@ packages: is-stream: 4.0.1 dev: true + /get-tsconfig@4.8.1: + resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==} + dependencies: + resolve-pkg-maps: 1.0.0 + dev: true + /git-up@7.0.0: resolution: {integrity: sha512-ONdIrbBCFusq1Oy0sC71F5azx8bVkvtZtMJAsv+a6lz5YAmbNnLD6HAB4gptHZVLPR8S2/kVN6Gab7lryq5+lQ==} dependencies: @@ -5384,6 +5641,10 @@ packages: engines: {node: '>=4'} dev: true + /resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + dev: true + /resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true @@ -6166,6 +6427,17 @@ packages: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} requiresBuild: true + /tsx@4.19.2: + resolution: {integrity: sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==} + engines: {node: '>=18.0.0'} + hasBin: true + dependencies: + esbuild: 0.23.1 + get-tsconfig: 4.8.1 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} diff --git a/services/db-migrations/migrations/1732483677017-dbs.js b/services/db-migrations/migrations/1732483677017-dbs.js index 7130e96..d9ccc9e 100644 --- a/services/db-migrations/migrations/1732483677017-dbs.js +++ b/services/db-migrations/migrations/1732483677017-dbs.js @@ -35,7 +35,11 @@ const dbsSecurity = { }, tg_bot: { admins: { roles: ["_admin"] }, - members: { roles: [] }, + members: { roles: ["_admin"] }, + }, + tg_bot_sessions: { + admins: { roles: ["_admin"] }, + members: { roles: ["_admin"] }, }, }; diff --git a/services/tg-bot/package.json b/services/tg-bot/package.json index ba4b171..9866357 100644 --- a/services/tg-bot/package.json +++ b/services/tg-bot/package.json @@ -28,6 +28,8 @@ "contributors": [], "scripts": { "build": "tsc", + "dev": "tsx src/bot/index.ts", + "dev:watch": "tsx --watch src/bot/index.ts", "type-check": "tsc --noEmit", "clean": "rm -rf dist", "lint": "eslint src", @@ -69,6 +71,7 @@ "esbuild": "^0.24.0", "esbuild-copy-static-files": "^0.1.0", "eslint": "^9.16.0", + "tsx": "^4.19.2", "typescript": "^5.7.2" } } diff --git a/services/tg-bot/src/bot/commands/manage.ts b/services/tg-bot/src/bot/commands/manage.ts index 13c4fca..b7a26e5 100644 --- a/services/tg-bot/src/bot/commands/manage.ts +++ b/services/tg-bot/src/bot/commands/manage.ts @@ -1,5 +1,6 @@ -import { MyContext } from "../../types/myContext"; -import { getQRCodesByUser } from "../../api/qrCodes"; +import { MyContext } from "@/types/myContext"; +import { getQRCodesByUser } from "@/api/qrCodes"; +import { buildManageKeyboard } from "@/bot/utils/keyboards"; /** * Обработка команды /manage с выводом подменю @@ -23,12 +24,7 @@ export async function manageCommand(ctx: MyContext) { } // Генерация клавиатуры с QR-кодами - const keyboard = qrCodes.map((qr) => [ - { - text: qr.name, - callback_data: `manage:${qr.uri}`, - }, - ]); + const keyboard = buildManageKeyboard(qrCodes); await ctx.reply("Выберите QR-код для управления:", { reply_markup: { diff --git a/services/tg-bot/src/bot/conversations/newQRCodeConversation.ts b/services/tg-bot/src/bot/conversations/newQRCodeConversation.ts index 5a96117..232dc96 100644 --- a/services/tg-bot/src/bot/conversations/newQRCodeConversation.ts +++ b/services/tg-bot/src/bot/conversations/newQRCodeConversation.ts @@ -68,14 +68,17 @@ export async function newQRCodeConversation( } // 3. Выбор действий, доступных при сканировании - await ctx.reply("Выберите доступные действия:", { - reply_markup: { - inline_keyboard: buildActionsKeyboard([ - { action: "sendMessage", label: "Отправить сообщение" }, - { action: "viewContactInfo", label: "Посмотреть контактные данные" }, - ]), + await ctx.reply( + "Выберите одно или несколько действий, которые будут доступны при сканировании этого QR-кода:", + { + reply_markup: { + inline_keyboard: buildActionsKeyboard([ + { action: "sendMessage", label: "Отправить сообщение" }, + { action: "viewContactInfo", label: "Посмотреть контактные данные" }, + ]), + }, }, - }); + ); const actionsMsg = await conversation.waitFor("callback_query:data"); const chosenAction = actionsMsg.update.callback_query?.data; diff --git a/services/tg-bot/src/bot/index.ts b/services/tg-bot/src/bot/index.ts index df75cad..60fc6c3 100644 --- a/services/tg-bot/src/bot/index.ts +++ b/services/tg-bot/src/bot/index.ts @@ -1,8 +1,10 @@ import { Bot, session } from "grammy"; import { conversations, createConversation } from "@grammyjs/conversations"; -import { MyContext } from "../types/myContext"; // Ваш расширенный контекст -import { env } from "../config/env"; +import { MyContext } from "@/types/myContext"; +import { env } from "@/config/env"; +import { CouchDBStorageAdapter } from "./storageAdapters/CouchDBStorageAdapter"; import { loadUserMiddleware } from "./middleware/loadUser"; +import { tgBotSessionsDb } from "@/dbs"; import { setupCommands } from "./commands/setupCommands"; import { startCommand } from "./commands/start"; import { helpCommand } from "./commands/help"; @@ -11,11 +13,15 @@ import { manageCommand } from "./commands/manage"; import { callbackQueryHandler } from "./callbackHandlers"; import { newQRCodeConversation } from "./conversations/newQRCodeConversation"; +const storage = new CouchDBStorageAdapter(tgBotSessionsDb, { + softDelete: true, +}); + // Инициализируем бота с нужным типом контекста const bot = new Bot(env.TELEGRAM_BOT_TOKEN); // Подключаем сессии. По необходимости определите initial state для сессии -bot.use(session({ initial: () => ({}) })); +bot.use(session({ storage, initial: () => ({}) })); // Подключаем conversations для многошаговых сценариев bot.use(conversations()); diff --git a/services/tg-bot/src/bot/storageAdapters/CouchDBStorageAdapter.ts b/services/tg-bot/src/bot/storageAdapters/CouchDBStorageAdapter.ts new file mode 100644 index 0000000..36d90ee --- /dev/null +++ b/services/tg-bot/src/bot/storageAdapters/CouchDBStorageAdapter.ts @@ -0,0 +1,131 @@ +import type { StorageAdapter } from "grammy"; + +export interface CouchDBStorageOptions { + /** Если true, при удалении сессии будет ставиться _deleted: true для поддержки репликаций по условиям полей */ + softDelete?: boolean; + type?: string; +} + +export interface CouchDBSessionDocument { + _id: string; + _rev?: string; + type: string; + session?: T; + created_at: string; + updated_at: string; + _deleted?: boolean; +} + +export class CouchDBStorageAdapter implements StorageAdapter { + constructor( + private db: PouchDB.Database>, + private options: CouchDBStorageOptions = { + softDelete: false, + type: "tg_bot_session", + }, + ) {} + + /** + * Чтение сессии по ключу. + * Возвращает данные сессии или undefined, если сессия не найдена. + */ + async read(key: string) { + try { + const id = this.getId(key); + const doc = await this.db.get(id); + return doc.session; + } catch (error: any) { + if (error.status !== 404) { + throw error; + } + } + } + + /** + * Запись или обновление сессии по ключу. + * При конфликте повторяет попытку записи. + */ + async write(key: string, value: T) { + const _id = this.getId(key); + await this.retryOnConflict(async () => { + let doc: CouchDBSessionDocument; + const updated_at = new Date().toISOString(); + try { + doc = await this.db.get(_id); + doc.session = value; + doc.updated_at = updated_at; + } catch (error: any) { + if (error.status === 404) { + // Документ не найден, создаём новый + doc = { + _id, + type: "tg_bot_session", + session: value, + created_at: updated_at, + updated_at, + }; + } else { + throw error; + } + } + + await this.db.put(doc); + }); + } + + /** + * Удаление сессии по ключу без предварительного получения документа. + * Если включен softDelete, пытаемся сразу поставить _deleted: true. + * Если нет softDelete, просто пытаемся удалить без rev, что приведёт к ошибке 409, + * тогда при конфликте получим актуальный _rev и удалим. + */ + async delete(key: string) { + const id = this.getId(key); + await this.retryOnConflict(async () => { + let doc: CouchDBSessionDocument; + try { + doc = await this.db.get(id); + } catch (error: any) { + if (error.status === 404) { + return; // Документ уже отсутствует + } else { + throw error; + } + } + + if (this.options.softDelete) { + doc._deleted = true; // Помечаем документ как удалённый + await this.db.put(doc); + } else { + await this.db.remove(id, doc._rev!); + } + }); + } + + /** + * Вспомогательная функция для повторения операции при конфликте. + * Если при put/remove случится конфликт, повторим попытку до retries раз. + * fn принимает параметр attempt (номер попытки, начиная с 1). + */ + private async retryOnConflict( + fn: (attempt: number) => Promise, + retries = 3, + ): Promise { + for (let i = 0; i < retries; i++) { + try { + await fn(i + 1); + return; + } catch (error: any) { + if (error.status === 409 && i < retries - 1) { + // Попытка ещё раз при конфликте + } else { + throw error; + } + } + } + } + + private getId(key: string) { + return this.options.type ? `${this.options.type}:${key}` : key; + } +} diff --git a/services/tg-bot/src/bot/utils/keyboards.ts b/services/tg-bot/src/bot/utils/keyboards.ts index 4bad85b..636af86 100644 --- a/services/tg-bot/src/bot/utils/keyboards.ts +++ b/services/tg-bot/src/bot/utils/keyboards.ts @@ -29,7 +29,7 @@ export function buildManageKeyboard( return qrCodes.map((qr) => [ { text: qr.name, - callback_data: `manage:${qr.uri}:delete`, // Пример - сразу кнопка на удаление. Можно сделать отдельное меню. + callback_data: `manage:${qr.uri}`, }, ]); } diff --git a/services/tg-bot/src/dbs.ts b/services/tg-bot/src/dbs.ts index 0ba79d1..1f7e870 100644 --- a/services/tg-bot/src/dbs.ts +++ b/services/tg-bot/src/dbs.ts @@ -12,6 +12,8 @@ import type { } from "@hereconnect/types"; import type { TelegramWebhookDesignDocument } from "@hereconnect/tg-bot-webhook-ddoc"; +import { CouchDBSessionDocument } from "@/bot/storageAdapters/CouchDBStorageAdapter"; +import type { SessionData } from "@/types/myContext"; // import type { TgUpdateDocument } from "./types"; if (!process.env.API_URL) { @@ -60,6 +62,13 @@ export const tgBotDesignDocumentsDb = tgBotDb as PouchDB.Database; export const serviceSeqTrackerDb = tgBotDb as ServiceSeqTrackerDatabase; +export const tgBotSessionsDb = new PouchDB>( + `${process.env.API_URL}/tg_bot_sessions`, + { + skip_setup: true, + }, +); + export const userChatSettingsDb = new PouchDB( `${process.env.API_URL}/user_chat_settings`, { @@ -84,6 +93,7 @@ export const checkDatabases = async () => { serviceSeqTrackerDb, userChatSettingsDb, tgBotDb, + tgBotSessionsDb, } as const).map( async ([name, db]): Promise< [string, Awaited>] diff --git a/services/tg-bot/src/services/qrGenerator.ts b/services/tg-bot/src/services/qrGenerator.ts index 497d67d..c49b53c 100644 --- a/services/tg-bot/src/services/qrGenerator.ts +++ b/services/tg-bot/src/services/qrGenerator.ts @@ -1,7 +1,5 @@ import QRCodeStyling from "qr-code-styling"; -import { optimizeSvg } from "../utils/images/svg/optimizeSvg"; -import { replaceFillInSvg } from "../utils/images/svg/replaceFillInSvg"; -import { addLabelToSvg } from "../utils/images/svg/addLabelToSvg"; +import { SvgProcessor } from "@/utils/images/svg/SvgProcessor"; import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; @@ -16,6 +14,7 @@ interface GenerateOptions { logoColor?: string; width?: number; height?: number; + padding?: number; } const __filename = fileURLToPath(import.meta.url); @@ -28,18 +27,21 @@ export async function generateQRCodeImage( label = "Сообщите о проблеме или задайте вопрос", color = "#000", logoColor = "#000", - width = 1024, - height = 1024, + width = 800, + height = 800, + padding = 32, }: GenerateOptions, ): Promise { + const optimizeOutput = format === "svg"; const logoPath = path.resolve(__dirname, "../../assets/logo.svg"); - let logoSvg = await fs.readFile(logoPath, "utf8"); - - // Заменяем цвет fill в лого - logoSvg = replaceFillInSvg(logoSvg, logoColor); - - // Добавляем лейбл "НаСвязи" в логотип (если нужно) - const logoWithText = addLabelToSvg(logoSvg, "НаСвязи", logoColor, 0.01); + const logoWithText = new SvgProcessor( + await fs.readFile(logoPath, "utf8"), + optimizeOutput, + ) + .replaceFill(logoColor) // Заменяем цвет fill в лого + .addLabel("НаСвязи", logoColor, 0.01) // Добавляем лейбл "НаСвязи" в логотип + // .addPadding(padding) // Добавляем отступы вокруг логотипа + .toString(); const qrCode = new QRCodeStyling({ jsdom: JSDOM, // this is required @@ -74,22 +76,22 @@ export async function generateQRCodeImage( } // Преобразуем SVG в строку - const svgText = rawData.toString("utf8"); - let optimizedSvg = optimizeSvg(svgText); - if (!optimizedSvg) { - throw new Error("Ошибка оптимизации SVG"); - } + let resultSvg = rawData.toString("utf8"); - // Добавляем текст, если указано - if (label) { - optimizedSvg = addLabelToSvg(optimizedSvg, label, color); - } + resultSvg = new SvgProcessor(resultSvg, optimizeOutput) + .addLabel(label, color) + .addPadding(padding) + .toString(); if (format === "svg") { + if (!resultSvg) { + throw new Error("Ошибка оптимизации SVG"); + } + // Если формат SVG, возвращаем буфер SVG - return Buffer.from(optimizedSvg, "utf8"); + return Buffer.from(resultSvg, "utf8"); } - // Если формат PNG, преобразуем optimizedSvg в PNG через sharp - return sharp(Buffer.from(optimizedSvg, "utf8")).toFormat("png").toBuffer(); + // Если формат PNG, преобразуем resultSvg в PNG через sharp + return sharp(Buffer.from(resultSvg, "utf8")).toFormat("png").toBuffer(); } diff --git a/services/tg-bot/src/types/myContext.ts b/services/tg-bot/src/types/myContext.ts index c6b7019..969a098 100644 --- a/services/tg-bot/src/types/myContext.ts +++ b/services/tg-bot/src/types/myContext.ts @@ -4,7 +4,7 @@ import { Context, SessionFlavor } from "grammy"; import { ConversationFlavor } from "@grammyjs/conversations"; import type { UserDocument } from "@hereconnect/types"; -interface SessionData { +export interface SessionData { step?: number; } diff --git a/services/tg-bot/src/utils/images/svg/SvgProcessor.ts b/services/tg-bot/src/utils/images/svg/SvgProcessor.ts new file mode 100644 index 0000000..7ecf5ca --- /dev/null +++ b/services/tg-bot/src/utils/images/svg/SvgProcessor.ts @@ -0,0 +1,193 @@ +import { + DOMParser, + XMLSerializer, + type Document as XmlDomDocument, + type Element as XmlElement, +} from "@xmldom/xmldom"; +import { optimize } from "svgo"; +import { calculateFontSizeForText } from "./calculateFontSizeForText"; + +export class SvgProcessor { + private doc: XmlDomDocument; + private svgElement: XmlElement; + + constructor( + svgText: string, + private optimizeOutput: boolean = false, + ) { + const parser = new DOMParser(); + this.doc = parser.parseFromString(svgText, "image/svg+xml"); + const svgElement = this.doc.getElementsByTagName("svg").item(0); + if (!svgElement) { + throw new Error("SVG не найден"); + } + this.svgElement = svgElement; + } + + addPadding(padding: number, backgroundColor = "transparent"): SvgProcessor { + const viewBox = this.svgElement + .getAttribute("viewBox") + ?.split(" ") + .map(Number); + if (!viewBox) { + throw new Error("viewBox не найден"); + } + const [x, y, width, height] = viewBox; + + // Увеличиваем размеры viewBox с учётом отступов + const newWidth = width + padding * 2; + const newHeight = height + padding * 2; + + const newViewBox = [x, y, newWidth, newHeight].join(" "); + this.svgElement.setAttribute("viewBox", newViewBox); + + // Добавляем прямоугольник для фона, если требуется + if (backgroundColor !== "transparent") { + const rect = this.doc.createElementNS( + "http://www.w3.org/2000/svg", + "rect", + ); + rect.setAttribute("x", x.toString()); + rect.setAttribute("y", y.toString()); + rect.setAttribute("width", newWidth.toString()); + rect.setAttribute("height", newHeight.toString()); + rect.setAttribute("fill", backgroundColor); + this.svgElement.insertBefore(rect, this.svgElement.firstChild); + } + + // Сдвигаем все элементы + const elements = Array.from(this.svgElement.childNodes).filter( + (node) => node.nodeType === 1 && node !== this.svgElement.firstChild, + ) as XmlElement[]; + + elements.forEach((el) => { + const isText = el.tagName === "text"; + + // Для текста добавляем дополнительное смещение + if (isText) { + const dy = el.getAttribute("dy") || "0"; + if (!dy.includes("%")) { + el.setAttribute("dy", (parseFloat(dy) - padding).toString()); + } + const dx = el.getAttribute("dx") || "0"; + if (!dx.includes("%") && !el.getAttribute("x")?.includes("%")) { + el.setAttribute("dx", padding.toString()); + } + } else { + const transform = el.getAttribute("transform") || ""; + el.setAttribute( + "transform", + `translate(${padding}, ${padding}) ${transform}`.trim(), + ); + } + }); + + // Удаляем атрибуты width и height, чтобы размеры SVG задавались только через viewBox + this.svgElement.removeAttribute("width"); + this.svgElement.removeAttribute("height"); + + return this; + } + + addLabel( + label: string, + color: string, + marginMultiplier: number = 0.1, + ): SvgProcessor { + if (!label) { + return this; + } + + const viewBoxAttr = this.svgElement.getAttribute("viewBox"); + if (!viewBoxAttr) { + throw new Error("viewBox не найден"); + } + + const viewBox: readonly number[] = viewBoxAttr.split(" ").map(Number); + const [x, y, width, height] = viewBox; + if (viewBox.length === 4) { + // Рассчитываем размер шрифта + const fontSize = calculateFontSizeForText(label, width); + const textMargin = Math.min(fontSize, height * marginMultiplier); + const extraBottomPadding = fontSize * 0.3; // 30% дополнительного пространства для висячих символов + + // Увеличиваем высоту viewBox + const newHeight = height + textMargin + fontSize; + this.svgElement.setAttribute( + "viewBox", + [x, y, width, newHeight].join(" "), + ); + + // Создаем элемент текста + const textElement = this.doc.createElementNS( + "http://www.w3.org/2000/svg", + "text", + ); + + textElement.setAttribute("x", "50%"); + textElement.setAttribute("y", "100%"); + textElement.setAttribute("dy", `-${extraBottomPadding}px`); + textElement.setAttribute("text-anchor", "middle"); + textElement.setAttribute("alignment-baseline", "baseline"); + textElement.setAttribute("dominant-baseline", "alphabetic"); + textElement.setAttribute("fill", color); + textElement.setAttribute("font-size", fontSize.toString()); + textElement.setAttribute("font-family", "Arial, sans-serif"); + textElement.textContent = label; + + this.svgElement.appendChild(textElement); + } + + // Удаляем атрибуты width и height, чтобы размеры SVG задавались только через viewBox + this.svgElement.removeAttribute("width"); + this.svgElement.removeAttribute("height"); + + return this; + } + + replaceFill(fillColor: string): SvgProcessor { + const elements = this.doc.getElementsByTagName("*"); + for (let i = 0; i < elements.length; i++) { + const el = elements.item(i); + if (el && el.hasAttribute("fill")) { + el.setAttribute("fill", fillColor); + } + } + + return this; + } + + /** + * Оптимизация SVG с использованием SVGO. + * @param svgText string изображения SVG. + * @returns Строка оптимизированного SVG. + */ + private optimizeSvg(svgText: string): string { + const optimizedSvg = optimize(svgText, { + plugins: [ + { name: "removeDoctype" }, + { name: "removeComments" }, + { name: "removeDimensions" }, + { name: "convertShapeToPath" }, + { name: "convertPathData" }, + { name: "mergePaths" }, + ], + }); + + if (!optimizedSvg.data) { + throw new Error("Ошибка оптимизации SVG"); + } + + return optimizedSvg.data; + } + + toString(): string { + const serializer = new XMLSerializer(); + const result = serializer.serializeToString(this.doc); + // console.log(result); + if (this.optimizeOutput) { + return this.optimizeSvg(result); + } + return result; + } +} diff --git a/services/tg-bot/src/utils/images/svg/addLabelToSvg.ts b/services/tg-bot/src/utils/images/svg/addLabelToSvg.ts deleted file mode 100644 index b9eebe9..0000000 --- a/services/tg-bot/src/utils/images/svg/addLabelToSvg.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; -import { calculateFontSizeForText } from "./calculateFontSizeForText"; - -/** - * Добавление текста в SVG, прибитого к нижнему краю. - * @param svgText Исходный SVG в виде строки. - * @param label Надпись для добавления. - * @param color Цвет текста. - * @param marginMultiplier Множитель относительного отступа. - * @returns SVG с добавленным текстом. - */ -export function addLabelToSvg( - svgText: string, - label: string, - color: string, - marginMultiplier: number = 0.1, -): string { - if (!label) { - return svgText; - } - - const parser = new DOMParser(); - const svgDoc = parser.parseFromString(svgText, "image/svg+xml"); - const svgElement = svgDoc.getElementsByTagName("svg").item(0); - - if (svgElement) { - const viewBoxAttr = svgElement.getAttribute("viewBox"); - if (viewBoxAttr) { - const viewBox = viewBoxAttr.split(" ").map(Number); - if (viewBox.length === 4) { - // Рассчитываем размер шрифта - const fontSize = calculateFontSizeForText(label, viewBox[2]); - const textMargin = Math.min(fontSize, viewBox[3] * marginMultiplier); - - // Увеличиваем высоту viewBox - viewBox[3] += fontSize + textMargin; - svgElement.setAttribute("viewBox", viewBox.join(" ")); - - // Создаем элемент текста - const textElement = svgDoc.createElementNS( - "http://www.w3.org/2000/svg", - "text", - ); - textElement.setAttribute("x", "50%"); - textElement.setAttribute("y", "100%"); - textElement.setAttribute("text-anchor", "middle"); - textElement.setAttribute("alignment-baseline", "baseline"); - textElement.setAttribute("dominant-baseline", "text-after-edge"); - textElement.setAttribute("fill", color); - textElement.setAttribute("font-size", fontSize.toString()); - textElement.setAttribute("font-family", "Arial, sans-serif"); - textElement.textContent = label; - - svgElement.appendChild(textElement); - } - } - } - - const serializer = new XMLSerializer(); - return serializer.serializeToString(svgDoc); -} diff --git a/services/tg-bot/src/utils/images/svg/optimizeSvg.ts b/services/tg-bot/src/utils/images/svg/optimizeSvg.ts deleted file mode 100644 index 4e3f246..0000000 --- a/services/tg-bot/src/utils/images/svg/optimizeSvg.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { optimize } from "svgo"; - -/** - * Оптимизация SVG с использованием SVGO. - * @param svgText string изображения SVG. - * @returns Строка оптимизированного SVG. - */ -export function optimizeSvg(svgText: string): string { - const optimizedSvg = optimize(svgText, { - plugins: [ - { name: "removeDoctype" }, - { name: "removeComments" }, - { name: "removeDimensions" }, - { name: "convertShapeToPath" }, - { name: "convertPathData" }, - { name: "mergePaths" }, - ], - }); - - if (!optimizedSvg.data) { - throw new Error("Ошибка оптимизации SVG"); - } - - return optimizedSvg.data; -} diff --git a/services/tg-bot/src/utils/images/svg/replaceFillInSvg.ts b/services/tg-bot/src/utils/images/svg/replaceFillInSvg.ts deleted file mode 100644 index 6acfe75..0000000 --- a/services/tg-bot/src/utils/images/svg/replaceFillInSvg.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; - -/** - * Заменяет атрибут `fill` в SVG-коде. - * @param svgText Исходный SVG в виде строки. - * @param fillColor Новый цвет для fill. - * @returns Измененный SVG-код. - */ -export function replaceFillInSvg(svgText: string, fillColor: string): string { - const parser = new DOMParser(); - const doc = parser.parseFromString(svgText, "image/svg+xml"); - - const elements = doc.getElementsByTagName("*"); - for (let i = 0; i < elements.length; i++) { - const el = elements.item(i); - if (el && el.hasAttribute("fill")) { - el.setAttribute("fill", fillColor); - } - } - - const serializer = new XMLSerializer(); - return serializer.serializeToString(doc); -} diff --git a/services/tg-bot/tsconfig.json b/services/tg-bot/tsconfig.json index de282de..09fe070 100644 --- a/services/tg-bot/tsconfig.json +++ b/services/tg-bot/tsconfig.json @@ -10,9 +10,8 @@ "skipLibCheck": true, // Пропуск проверки типов в библиотечных файлах "declaration": true, // Генерация `.d.ts` файлов "sourceMap": true, // Генерация `.map` файлов - "baseUrl": "./", // Базовая директория для путей "paths": { - "*": ["src/*"] // Настройка алиасов (сопоставление путей) + "@/*": ["./src/*"] // Настройка алиасов (сопоставление путей) } }, "include": ["src"], // Указываем папку с исходниками