diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 0800b7150..2ee25b4c3 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -975,6 +975,35 @@ services: retries: 3 start_period: 40s + # ============================================ + # Matrix Calendar Bot (GDPR-compliant Calendar) + # ============================================ + + matrix-calendar-bot: + image: matrix-calendar-bot:latest + container_name: manacore-matrix-calendar-bot + restart: always + depends_on: + synapse: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3315 + TZ: Europe/Berlin + MATRIX_HOMESERVER_URL: http://synapse:8008 + MATRIX_ACCESS_TOKEN: ${MATRIX_CALENDAR_BOT_TOKEN} + MATRIX_ALLOWED_ROOMS: ${MATRIX_CALENDAR_BOT_ROOMS:-} + volumes: + - matrix_calendar_bot_data:/app/data + ports: + - "3315:3315" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3315/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + # ============================================ # Matrix Todo Bot (GDPR-compliant Task Management) # ============================================ @@ -1004,6 +1033,39 @@ services: retries: 3 start_period: 40s + # ============================================ + # Matrix NutriPhi Bot (GDPR-compliant Nutrition Tracking) + # ============================================ + + matrix-nutriphi-bot: + image: matrix-nutriphi-bot:latest + container_name: manacore-matrix-nutriphi-bot + restart: always + depends_on: + synapse: + condition: service_healthy + nutriphi-backend: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3316 + TZ: Europe/Berlin + MATRIX_HOMESERVER_URL: http://synapse:8008 + MATRIX_ACCESS_TOKEN: ${MATRIX_NUTRIPHI_BOT_TOKEN} + MATRIX_ALLOWED_ROOMS: ${MATRIX_NUTRIPHI_BOT_ROOMS:-} + NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3023 + MANA_CORE_AUTH_URL: http://mana-core-auth:3001 + volumes: + - matrix_nutriphi_bot_data:/app/data + ports: + - "3316:3316" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3316/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + # ============================================ # Auto-Update (Watchtower) # ============================================ @@ -1052,5 +1114,9 @@ volumes: name: manacore-matrix-stats-bot matrix_project_doc_bot_data: name: manacore-matrix-project-doc-bot + matrix_calendar_bot_data: + name: manacore-matrix-calendar-bot matrix_todo_bot_data: name: manacore-matrix-todo-bot + matrix_nutriphi_bot_data: + name: manacore-matrix-nutriphi-bot diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d69c7533..44a5b6a5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,7 +128,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^10.4.9 - version: 10.4.9(esbuild@0.19.12) + version: 10.4.9 '@nestjs/schematics': specifier: ^10.2.3 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) @@ -179,7 +179,7 @@ importers: version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)) + version: 9.5.4(typescript@5.9.3)(webpack@5.100.2) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -203,14 +203,14 @@ importers: version: link:../../../../packages/shared-landing-ui astro: specifier: ^5.16.0 - version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) + version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) typescript: specifier: ^5.9.2 version: 5.9.3 devDependencies: '@astrojs/tailwind': specifier: ^6.0.2 - version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) '@tailwindcss/typography': specifier: ^0.5.18 version: 0.5.19(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1)) @@ -219,13 +219,13 @@ importers: version: 20.19.25 eslint: specifier: ^9.0.0 - version: 9.39.1(jiti@1.21.7) + version: 9.39.1(jiti@2.6.1) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.2(eslint@9.39.1(jiti@1.21.7)) + version: 9.1.2(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-astro: specifier: ^1.0.0 - version: 1.5.0(eslint@9.39.1(jiti@1.21.7)) + version: 1.5.0(eslint@9.39.1(jiti@2.6.1)) prettier: specifier: ^3.6.2 version: 3.6.2 @@ -585,19 +585,19 @@ importers: version: 18.3.27 '@typescript-eslint/eslint-plugin': specifier: ^7.7.0 - version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/parser': specifier: ^7.7.0 - version: 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + version: 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) dotenv: specifier: ^16.4.7 version: 16.6.1 eslint: specifier: ^9.39.1 - version: 9.39.1(jiti@2.6.1) + version: 9.39.1(jiti@1.21.7) eslint-config-universe: specifier: ^12.0.1 - version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3) + version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3) prettier: specifier: ^3.2.5 version: 3.6.2 @@ -4535,7 +4535,7 @@ importers: version: 1.57.0 jest: specifier: ^29.0.0 - version: 29.7.0(@types/node@24.10.1) + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) vitest: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) @@ -4639,7 +4639,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^11.0.0 - version: 11.0.12(@types/node@22.19.1) + version: 11.0.12(@types/node@22.19.1)(esbuild@0.19.12) '@nestjs/schematics': specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) @@ -4699,7 +4699,7 @@ importers: version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.100.2) + version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -4713,6 +4713,80 @@ importers: specifier: ^5.7.2 version: 5.9.3 + services/matrix-calendar-bot: + dependencies: + '@nestjs/common': + specifier: ^10.4.15 + version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^3.3.0 + version: 3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.4.15 + version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^10.4.15 + version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) + matrix-bot-sdk: + specifier: ^0.7.1 + version: 0.7.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + devDependencies: + '@nestjs/cli': + specifier: ^10.4.9 + version: 10.4.9 + '@nestjs/schematics': + specifier: ^10.2.3 + version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) + '@types/node': + specifier: ^22.10.5 + version: 22.19.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + services/matrix-nutriphi-bot: + dependencies: + '@nestjs/common': + specifier: ^10.4.15 + version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^3.3.0 + version: 3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.4.15 + version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^10.4.15 + version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) + matrix-bot-sdk: + specifier: ^0.7.1 + version: 0.7.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + devDependencies: + '@nestjs/cli': + specifier: ^10.4.9 + version: 10.4.9 + '@nestjs/schematics': + specifier: ^10.2.3 + version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) + '@types/node': + specifier: ^22.10.5 + version: 22.19.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + services/matrix-ollama-bot: dependencies: '@nestjs/common': @@ -4854,6 +4928,43 @@ importers: specifier: ^5.7.3 version: 5.9.3 + services/matrix-todo-bot: + dependencies: + '@nestjs/common': + specifier: ^10.4.15 + version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^3.3.0 + version: 3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.4.15 + version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^10.4.15 + version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) + matrix-bot-sdk: + specifier: ^0.7.1 + version: 0.7.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + devDependencies: + '@nestjs/cli': + specifier: ^10.4.9 + version: 10.4.9 + '@nestjs/schematics': + specifier: ^10.2.3 + version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) + '@types/node': + specifier: ^22.10.5 + version: 22.19.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + services/telegram-nutriphi-bot: dependencies: '@google/generative-ai': @@ -7488,7 +7599,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -19499,10 +19610,12 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me tar@7.5.2: resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} @@ -21306,16 +21419,6 @@ snapshots: transitivePeerDependencies: - ts-node - '@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': - dependencies: - astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) - autoprefixer: 10.4.22(postcss@8.5.6) - postcss: 8.5.6 - postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) - tailwindcss: 3.4.18(tsx@4.20.6)(yaml@2.8.1) - transitivePeerDependencies: - - ts-node - '@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': dependencies: astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) @@ -24356,7 +24459,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 6.0.15(7mqaurqidri6vkknnsci36yp4e) + expo-router: 6.0.15(vmxlpuhz6xqbe2ee7fdabyqx3y) react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -25627,7 +25730,7 @@ snapshots: jest-util: 30.2.0 slash: 3.0.0 - '@jest/core@29.7.0': + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -25641,7 +25744,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.19.1) + jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -25662,7 +25765,7 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -25676,7 +25779,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -26141,32 +26244,6 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/cli@10.4.9(esbuild@0.19.12)': - dependencies: - '@angular-devkit/core': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics-cli': 17.3.11(chokidar@3.6.0) - '@nestjs/schematics': 10.2.3(chokidar@3.6.0)(typescript@5.7.2) - chalk: 4.1.2 - chokidar: 3.6.0 - cli-table3: 0.6.5 - commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.19.12)) - glob: 10.4.5 - inquirer: 8.2.6 - node-emoji: 1.11.0 - ora: 5.4.1 - tree-kill: 1.2.2 - tsconfig-paths: 4.2.0 - tsconfig-paths-webpack-plugin: 4.2.0 - typescript: 5.7.2 - webpack: 5.97.1(esbuild@0.19.12) - webpack-node-externals: 3.0.0 - transitivePeerDependencies: - - esbuild - - uglify-js - - webpack-cli - '@nestjs/cli@10.4.9(esbuild@0.27.0)': dependencies: '@angular-devkit/core': 17.3.11(chokidar@3.6.0) @@ -26193,7 +26270,7 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/cli@11.0.12(@types/node@22.19.1)': + '@nestjs/cli@11.0.12(@types/node@22.19.1)(esbuild@0.19.12)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) @@ -26204,14 +26281,14 @@ snapshots: chokidar: 4.0.3 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.100.2) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)) glob: 12.0.0 node-emoji: 1.11.0 ora: 5.4.1 tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.9.3 - webpack: 5.100.2 + webpack: 5.100.2(esbuild@0.19.12) webpack-node-externals: 3.0.0 transitivePeerDependencies: - '@types/node' @@ -29572,19 +29649,6 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - jest-matcher-utils: 30.2.0 - picocolors: 1.1.1 - pretty-format: 30.2.0 - react: 19.1.0 - react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) - react-test-renderer: 19.1.0(react@19.1.0) - redent: 3.0.0 - optionalDependencies: - jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) - optional: true - '@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: jest-matcher-utils: 30.2.0 @@ -30132,16 +30196,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -30190,15 +30254,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.18.0 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -30290,14 +30354,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -30329,14 +30393,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.18.0 debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -30462,12 +30526,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) ts-api-utils: 1.4.3(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -30498,12 +30562,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) ts-api-utils: 1.4.3(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -30685,15 +30749,15 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) '@types/json-schema': 7.0.15 '@types/semver': 7.7.1 '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) semver: 7.7.3 transitivePeerDependencies: - supports-color @@ -30724,13 +30788,13 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) transitivePeerDependencies: - supports-color - typescript @@ -31583,108 +31647,6 @@ snapshots: transitivePeerDependencies: - supports-color - astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1): - dependencies: - '@astrojs/compiler': 2.13.0 - '@astrojs/internal-helpers': 0.7.5 - '@astrojs/markdown-remark': 6.3.9 - '@astrojs/telemetry': 3.3.0 - '@capsizecss/unpack': 3.0.1 - '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.53.3) - acorn: 8.15.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - boxen: 8.0.1 - ci-info: 4.3.1 - clsx: 2.1.1 - common-ancestor-path: 1.0.1 - cookie: 1.1.0 - cssesc: 3.0.0 - debug: 4.4.3 - deterministic-object-hash: 2.0.2 - devalue: 5.5.0 - diff: 5.2.0 - dlv: 1.1.3 - dset: 3.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.25.12 - estree-walker: 3.0.3 - flattie: 1.1.1 - fontace: 0.3.1 - github-slugger: 2.0.0 - html-escaper: 3.0.3 - http-cache-semantics: 4.2.0 - import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 - magic-string: 0.30.21 - magicast: 0.5.1 - mrmime: 2.0.1 - neotraverse: 0.6.18 - p-limit: 6.2.0 - p-queue: 8.1.1 - package-manager-detector: 1.5.0 - piccolore: 0.1.3 - picomatch: 4.0.3 - prompts: 2.4.2 - rehype: 13.0.2 - semver: 7.7.3 - shiki: 3.15.0 - smol-toml: 1.5.2 - svgo: 4.0.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tsconfck: 3.1.6(typescript@5.9.3) - ultrahtml: 1.6.0 - unifont: 0.6.0 - unist-util-visit: 5.0.0 - unstorage: 1.17.3(@netlify/blobs@10.4.1)(ioredis@5.8.2) - vfile: 6.0.3 - vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) - vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) - xxhash-wasm: 1.1.0 - yargs-parser: 21.1.1 - yocto-spinner: 0.2.3 - zod: 3.25.76 - zod-to-json-schema: 3.25.0(zod@3.25.76) - zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) - optionalDependencies: - sharp: 0.34.5 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - db0 - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - rollup - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - yaml - astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1): dependencies: '@astrojs/compiler': 2.13.0 @@ -32942,13 +32904,13 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@24.10.1): + create-jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@24.10.1) + jest-config: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -34023,11 +33985,6 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - semver: 7.7.3 - eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -34038,9 +33995,9 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -34055,9 +34012,9 @@ snapshots: '@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) eslint: 9.39.1(jiti@2.6.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 0.1.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -34075,14 +34032,14 @@ snapshots: dependencies: eslint: 8.57.1 + eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) - eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -34107,17 +34064,17 @@ snapshots: - supports-color - typescript - eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3): + eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3): dependencies: - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - eslint: 9.39.1(jiti@2.6.1) - eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) - eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@2.6.1)) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + eslint: 9.39.1(jiti@1.21.7) + eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2) + eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@1.21.7)) optionalDependencies: prettier: 3.6.2 transitivePeerDependencies: @@ -34155,7 +34112,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -34166,22 +34123,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) - get-tsconfig: 4.13.0 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -34195,12 +34137,12 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - eslint: 9.39.1(jiti@2.6.1) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color @@ -34215,39 +34157,25 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@1.21.7)): - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) - '@jridgewell/sourcemap-codec': 1.5.5 - '@typescript-eslint/types': 8.48.0 - astro-eslint-parser: 1.2.2 - eslint: 9.39.1(jiti@1.21.7) - eslint-compat-utils: 0.6.5(eslint@9.39.1(jiti@1.21.7)) - globals: 16.5.0 - postcss: 8.5.6 - postcss-selector-parser: 7.1.0 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -34271,6 +34199,12 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 + eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-utils: 2.1.0 + regexpp: 3.2.0 + eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -34324,7 +34258,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -34333,9 +34267,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -34347,7 +34281,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -34382,7 +34316,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -34393,7 +34327,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -34411,7 +34345,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -34422,7 +34356,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -34450,6 +34384,16 @@ snapshots: resolve: 1.22.11 semver: 6.3.1 + eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-plugin-es: 3.0.1(eslint@9.39.1(jiti@1.21.7)) + eslint-utils: 2.1.0 + ignore: 5.3.2 + minimatch: 3.1.2 + resolve: 1.22.11 + semver: 6.3.1 + eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -34480,6 +34424,16 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 8.10.2(eslint@8.57.1) + eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + prettier: 3.6.2 + prettier-linter-helpers: 1.0.0 + synckit: 0.11.11 + optionalDependencies: + '@types/eslint': 9.6.1 + eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -34504,6 +34458,10 @@ snapshots: dependencies: eslint: 8.57.1 + eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -34534,6 +34492,28 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 + eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@1.21.7)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.1 + eslint: 9.39.1(jiti@1.21.7) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)): dependencies: array-includes: 3.1.9 @@ -35647,53 +35627,6 @@ snapshots: - react-native - supports-color - expo-router@6.0.15(7mqaurqidri6vkknnsci36yp4e): - dependencies: - '@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - '@expo/schema-utils': 0.1.7 - '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.0) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@react-navigation/bottom-tabs': 7.8.6(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - '@react-navigation/native': 7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - '@react-navigation/native-stack': 7.8.0(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - client-only: 0.0.1 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)) - expo-linking: 8.0.9(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - expo-server: 1.0.4 - fast-deep-equal: 3.1.3 - invariant: 2.2.4 - nanoid: 3.3.11 - query-string: 7.1.3 - react: 19.1.0 - react-fast-compare: 3.2.2 - react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) - react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - semver: 7.6.3 - server-only: 0.0.1 - sf-symbols-typescript: 2.1.0 - shallowequal: 1.1.0 - use-latest-callback: 0.2.6(react@19.1.0) - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - optionalDependencies: - '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) - react-dom: 19.1.0(react@19.1.0) - react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) - react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react-server-dom-webpack: 19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.97.1(esbuild@0.19.12)) - transitivePeerDependencies: - - '@react-native-masked-view/masked-view' - - '@types/react' - - '@types/react-dom' - - supports-color - optional: true - expo-router@6.0.15(k2muy65dii4k2uiuhg4mwyy6ki): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) @@ -36772,23 +36705,6 @@ snapshots: forever-agent@0.6.1: {} - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.19.12)): - dependencies: - '@babel/code-frame': 7.27.1 - chalk: 4.1.2 - chokidar: 3.6.0 - cosmiconfig: 8.3.6(typescript@5.7.2) - deepmerge: 4.3.1 - fs-extra: 10.1.0 - memfs: 3.5.3 - minimatch: 3.1.2 - node-abort-controller: 3.1.1 - schema-utils: 3.3.0 - semver: 7.7.3 - tapable: 2.3.0 - typescript: 5.7.2 - webpack: 5.97.1(esbuild@0.19.12) - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.27.0)): dependencies: '@babel/code-frame': 7.27.1 @@ -36823,6 +36739,23 @@ snapshots: typescript: 5.7.2 webpack: 5.97.1 + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)): + dependencies: + '@babel/code-frame': 7.27.1 + chalk: 4.1.2 + chokidar: 4.0.3 + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.2 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.7.3 + tapable: 2.3.0 + typescript: 5.9.3 + webpack: 5.100.2(esbuild@0.19.12) + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)): dependencies: '@babel/code-frame': 7.27.1 @@ -36840,23 +36773,6 @@ snapshots: typescript: 5.9.3 webpack: 5.100.2(esbuild@0.27.0) - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.100.2): - dependencies: - '@babel/code-frame': 7.27.1 - chalk: 4.1.2 - chokidar: 4.0.3 - cosmiconfig: 8.3.6(typescript@5.9.3) - deepmerge: 4.3.1 - fs-extra: 10.1.0 - memfs: 3.5.3 - minimatch: 3.1.2 - node-abort-controller: 3.1.1 - schema-utils: 3.3.0 - semver: 7.7.3 - tapable: 2.3.0 - typescript: 5.9.3 - webpack: 5.100.2 - form-data-encoder@1.7.2: {} form-data@2.3.3: @@ -38102,16 +38018,16 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@24.10.1): + jest-cli@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@24.10.1) + create-jest: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@24.10.1) + jest-config: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -38199,36 +38115,6 @@ snapshots: - ts-node optional: true - jest-config@29.7.0(@types/node@22.19.1): - dependencies: - '@babel/core': 7.28.5 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 22.19.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 @@ -38260,7 +38146,38 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@24.10.1): + jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.28.5 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.19.1 + ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 '@jest/test-sequencer': 29.7.0 @@ -38286,6 +38203,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 24.10.1 + ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -38872,12 +38790,12 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@24.10.1): + jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0 + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@24.10.1) + jest-cli: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -43041,16 +42959,6 @@ snapshots: webpack-sources: 3.3.3 optional: true - react-server-dom-webpack@19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.97.1(esbuild@0.19.12)): - dependencies: - acorn-loose: 8.5.2 - neo-async: 2.6.2 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - webpack: 5.97.1(esbuild@0.19.12) - webpack-sources: 3.3.3 - optional: true - react-style-singleton@2.2.3(@types/react@18.3.27)(react@18.3.1): dependencies: get-nonce: 1.0.1 @@ -44418,14 +44326,14 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.97.1(esbuild@0.19.12)): + terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.44.1 - webpack: 5.97.1(esbuild@0.19.12) + webpack: 5.100.2(esbuild@0.19.12) optionalDependencies: esbuild: 0.19.12 @@ -44692,6 +44600,16 @@ snapshots: esbuild: 0.27.0 jest-util: 30.2.0 + ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)): + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.18.3 + micromatch: 4.0.8 + semver: 7.7.3 + source-map: 0.7.6 + typescript: 5.9.3 + webpack: 5.100.2(esbuild@0.19.12) + ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)): dependencies: chalk: 4.1.2 @@ -44712,16 +44630,6 @@ snapshots: typescript: 5.9.3 webpack: 5.100.2 - ts-loader@9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)): - dependencies: - chalk: 4.1.2 - enhanced-resolve: 5.18.3 - micromatch: 4.0.8 - semver: 7.7.3 - source-map: 0.7.6 - typescript: 5.9.3 - webpack: 5.97.1(esbuild@0.19.12) - ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -45390,23 +45298,6 @@ snapshots: lightningcss: 1.30.2 terser: 5.44.1 - vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.3 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 20.19.25 - fsevents: 2.3.3 - jiti: 1.21.7 - lightningcss: 1.30.2 - terser: 5.44.1 - tsx: 4.20.6 - yaml: 2.8.1 - vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.12 @@ -45509,10 +45400,6 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 - vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)): - optionalDependencies: - vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) - vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)): optionalDependencies: vite: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) @@ -45866,6 +45753,38 @@ snapshots: - esbuild - uglify-js + webpack@5.100.2(esbuild@0.19.12): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + acorn-import-phases: 1.0.4(acorn@8.15.0) + browserslist: 4.28.0 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.3 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12)) + watchpack: 2.4.4 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + webpack@5.100.2(esbuild@0.27.0): dependencies: '@types/eslint-scope': 3.7.7 @@ -45928,36 +45847,6 @@ snapshots: - esbuild - uglify-js - webpack@5.97.1(esbuild@0.19.12): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.8 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - browserslist: 4.28.0 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.3 - es-module-lexer: 1.7.0 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.1 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.3.0 - terser-webpack-plugin: 5.3.14(esbuild@0.19.12)(webpack@5.97.1(esbuild@0.19.12)) - watchpack: 2.4.4 - webpack-sources: 3.3.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - webpack@5.97.1(esbuild@0.27.0): dependencies: '@types/eslint-scope': 3.7.7 diff --git a/services/matrix-calendar-bot/.dockerignore b/services/matrix-calendar-bot/.dockerignore new file mode 100644 index 000000000..d6a8859ae --- /dev/null +++ b/services/matrix-calendar-bot/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +*.log +.env* +data diff --git a/services/matrix-calendar-bot/CLAUDE.md b/services/matrix-calendar-bot/CLAUDE.md new file mode 100644 index 000000000..6d9f782e3 --- /dev/null +++ b/services/matrix-calendar-bot/CLAUDE.md @@ -0,0 +1,178 @@ +# Matrix Calendar Bot - Claude Code Guidelines + +## Overview + +Matrix Calendar Bot provides a GDPR-compliant calendar/event management interface via Matrix chat. It uses the Matrix protocol for messaging, allowing self-hosting all data on the Mac Mini server. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Storage**: Local JSON file (per-user events) + +## Commands + +```bash +# Development +pnpm install +pnpm start:dev # Start with hot reload + +# Build +pnpm build # Production build + +# Type check +pnpm type-check # Check TypeScript types +``` + +## Project Structure + +``` +services/matrix-calendar-bot/ +├── src/ +│ ├── main.ts # Application entry point +│ ├── app.module.ts # Root module +│ ├── health.controller.ts # Health check endpoint +│ ├── config/ +│ │ └── configuration.ts # Configuration & help texts +│ ├── bot/ +│ │ ├── bot.module.ts +│ │ └── matrix.service.ts # Matrix client & command handlers +│ └── calendar/ +│ ├── calendar.module.ts +│ └── calendar.service.ts # Event storage & management +├── Dockerfile +└── package.json +``` + +## Matrix Commands + +| Command | Description | +|---------|-------------| +| `!help` | Show help message | +| `!heute` / `!today` | Show today's events | +| `!morgen` / `!tomorrow` | Show tomorrow's events | +| `!woche` / `!week` | Show this week's events | +| `!termine` | Show next 14 days | +| `!termin [...]` | Create new event | +| `!details [nr]` | Show event details | +| `!löschen [nr]` | Delete event | +| `!kalender` | Show calendars | +| `!status` | Bot status | +| `!pin` | Pin help to room | + +## Natural Language Keywords + +The bot also responds to natural language (German + English): +- "hilfe", "help" → Show help +- "was steht heute an", "termine heute" → Today's events +- "termine morgen" → Tomorrow's events +- "diese woche", "wochenübersicht" → Week events +- "zeige kalender" → Show calendars + +## Event Input Syntax + +``` +!termin Meeting am 15.02. um 14:00 + │ │ └── Time (optional, defaults to 9:00) + │ └── Date (DD.MM. or DD.MM.YYYY) + └── Event title + +!termin Zahnarzt morgen um 10:30 + │ └── Time + └── Relative date (heute, morgen, übermorgen) + +!termin Geburtstag am 20.03. ganztägig + └── All-day event flag +``` + +## Environment Variables + +```env +# Server +PORT=3315 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#calendar-bot:mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Calendar API (optional, for future integration) +CALENDAR_API_URL=http://localhost:3016/api/v1 +``` + +## Docker + +```bash +# Build locally +docker build -f services/matrix-calendar-bot/Dockerfile -t matrix-calendar-bot services/matrix-calendar-bot + +# Run +docker run -p 3315:3315 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -v matrix-calendar-bot-data:/app/data \ + matrix-calendar-bot +``` + +## Health Check + +```bash +curl http://localhost:3315/health +``` + +## Getting a Matrix Access Token + +```bash +# Login to get access token +curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "m.login.password", + "user": "calendar-bot", + "password": "your-password" + }' + +# Response contains: {"access_token": "syt_xxx", ...} +``` + +## Data Storage + +Events are stored in a local JSON file (`/app/data/calendar-data.json`) with per-user isolation. + +Structure: +```json +{ + "events": [ + { + "id": "unique-id", + "title": "Event title", + "description": null, + "location": null, + "startTime": "2024-02-15T14:00:00.000Z", + "endTime": "2024-02-15T15:00:00.000Z", + "isAllDay": false, + "calendarId": "cal-id", + "calendarName": "Mein Kalender", + "createdAt": "2024-01-27T10:00:00Z", + "userId": "@user:mana.how" + } + ], + "calendars": [ + { + "id": "cal-id", + "name": "Mein Kalender", + "color": "#3B82F6", + "userId": "@user:mana.how" + } + ] +} +``` + +## GDPR Compliance + +- All event data stored locally on Mac Mini +- No third-party data processing +- Full control over data retention +- Per-user data isolation via Matrix user IDs +- Can delete all user data on request diff --git a/services/matrix-calendar-bot/Dockerfile b/services/matrix-calendar-bot/Dockerfile new file mode 100644 index 000000000..7cac324a8 --- /dev/null +++ b/services/matrix-calendar-bot/Dockerfile @@ -0,0 +1,48 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json ./ + +# Install all dependencies (including devDependencies for build) +RUN npm install + +# Copy source code +COPY . . + +# Build TypeScript +RUN rm -rf dist && npx tsc -p tsconfig.build.json + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# Create data directory for storage +RUN mkdir -p /app/data + +# Copy package files +COPY package.json ./ + +# Install production dependencies only +RUN npm install --omit=dev + +# Copy built application +COPY --from=builder /app/dist ./dist + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nestjs -u 1001 && \ + chown -R nestjs:nodejs /app + +USER nestjs + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3315/health || exit 1 + +EXPOSE 3315 + +CMD ["node", "dist/main.js"] diff --git a/services/matrix-calendar-bot/nest-cli.json b/services/matrix-calendar-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/matrix-calendar-bot/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/matrix-calendar-bot/package.json b/services/matrix-calendar-bot/package.json new file mode 100644 index 000000000..9b561d3e3 --- /dev/null +++ b/services/matrix-calendar-bot/package.json @@ -0,0 +1,44 @@ +{ + "name": "@manacore/matrix-calendar-bot", + "version": "1.0.0", + "description": "Matrix bot for calendar management - GDPR compliant", + "private": true, + "license": "MIT", + "pnpm": { + "neverBuiltDependencies": [ + "@matrix-org/matrix-sdk-crypto-nodejs" + ], + "overrides": { + "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" + } + }, + "overrides": { + "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" + }, + "scripts": { + "prebuild": "rm -rf dist || true", + "build": "tsc -p tsconfig.build.json", + "format": "prettier --write \"src/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "matrix-bot-sdk": "^0.7.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } +} diff --git a/services/matrix-calendar-bot/src/app.module.ts b/services/matrix-calendar-bot/src/app.module.ts new file mode 100644 index 000000000..50eef3f55 --- /dev/null +++ b/services/matrix-calendar-bot/src/app.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import configuration from './config/configuration'; +import { BotModule } from './bot/bot.module'; +import { CalendarModule } from './calendar/calendar.module'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + CalendarModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-calendar-bot/src/bot/bot.module.ts b/services/matrix-calendar-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..7fb224a6d --- /dev/null +++ b/services/matrix-calendar-bot/src/bot/bot.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { CalendarModule } from '../calendar/calendar.module'; + +@Module({ + imports: [CalendarModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-calendar-bot/src/bot/matrix.service.ts b/services/matrix-calendar-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..c637c57ee --- /dev/null +++ b/services/matrix-calendar-bot/src/bot/matrix.service.ts @@ -0,0 +1,550 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + AutojoinRoomsMixin, + RichReply, +} from 'matrix-bot-sdk'; +import * as path from 'path'; +import * as fs from 'fs'; +import { CalendarService, CalendarEvent } from '../calendar/calendar.service'; +import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration'; + +// Natural language keywords that trigger commands (German + English) +const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ + { keywords: ['hilfe', 'help', 'was kannst du', 'befehle', 'commands'], command: 'help' }, + { + keywords: ['was steht heute an', 'termine heute', 'heute termine', "today's events"], + command: 'today', + }, + { keywords: ['termine morgen', 'morgen termine', 'was ist morgen'], command: 'tomorrow' }, + { + keywords: ['diese woche', 'wochenübersicht', 'week', 'woche'], + command: 'week', + }, + { keywords: ['zeige kalender', 'meine kalender', 'calendars'], command: 'calendars' }, + { keywords: ['status', 'verbindung', 'connection'], command: 'status' }, +]; + +@Injectable() +export class MatrixService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MatrixService.name); + private client: MatrixClient; + private readonly homeserverUrl: string; + private readonly accessToken: string; + private readonly allowedRooms: string[]; + private readonly storagePath: string; + + constructor( + private configService: ConfigService, + private calendarService: CalendarService + ) { + this.homeserverUrl = this.configService.get( + 'matrix.homeserverUrl', + 'http://localhost:8008' + ); + this.accessToken = this.configService.get('matrix.accessToken', ''); + this.allowedRooms = this.configService.get('matrix.allowedRooms', []); + this.storagePath = this.configService.get( + 'matrix.storagePath', + './data/bot-storage.json' + ); + } + + async onModuleInit() { + if (!this.accessToken) { + this.logger.warn('No Matrix access token configured. Bot will not start.'); + return; + } + + await this.initializeClient(); + } + + async onModuleDestroy() { + if (this.client) { + await this.client.stop(); + } + } + + private async initializeClient() { + try { + // Ensure storage directory exists + const storageDir = path.dirname(this.storagePath); + if (!fs.existsSync(storageDir)) { + fs.mkdirSync(storageDir, { recursive: true }); + } + + const storage = new SimpleFsStorageProvider(this.storagePath); + this.client = new MatrixClient(this.homeserverUrl, this.accessToken, storage); + + // Auto-join rooms when invited + AutojoinRoomsMixin.setupOnClient(this.client); + + // Handle room invites with introduction + this.client.on('room.invite', async (roomId: string) => { + this.logger.log(`Invited to room ${roomId}, joining...`); + await this.client.joinRoom(roomId); + + // Send introduction after a short delay + setTimeout(async () => { + try { + await this.sendBotIntroduction(roomId); + } catch (error) { + this.logger.error(`Failed to send introduction to ${roomId}:`, error); + } + }, 2000); + }); + + // Handle member joins for welcome message + this.client.on('room.event', async (roomId: string, event: any) => { + if (event.type === 'm.room.member' && event.content?.membership === 'join') { + const odUser = event.state_key; + const botUserId = await this.client.getUserId(); + + // Don't welcome the bot itself + if (odUser === botUserId) return; + + // Check if this is a new join (not just profile update) + if (event.unsigned?.prev_content?.membership !== 'join') { + await this.sendWelcomeMessage(roomId, odUser); + } + } + }); + + // Set up message handler + this.client.on('room.message', async (roomId: string, event: any) => { + await this.handleMessage(roomId, event); + }); + + await this.client.start(); + this.logger.log(`Matrix Calendar Bot connected to ${this.homeserverUrl}`); + + const odUser = await this.client.getUserId(); + this.logger.log(`Bot user ID: ${odUser}`); + + if (this.allowedRooms.length > 0) { + this.logger.log(`Allowed rooms: ${this.allowedRooms.join(', ')}`); + } else { + this.logger.log('No room restrictions - bot will respond in all rooms'); + } + } catch (error) { + this.logger.error('Failed to initialize Matrix client:', error); + } + } + + private async handleMessage(roomId: string, event: any) { + // Ignore messages from the bot itself + const botUserId = await this.client.getUserId(); + if (event.sender === botUserId) return; + + // Check if room is allowed + if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { + this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`); + return; + } + + // Only handle text messages + if (event.content?.msgtype !== 'm.text') return; + + const body = event.content.body?.trim(); + if (!body) return; + + const odUser = event.sender; + + try { + // Check for ! commands first (before keyword detection) + if (body.startsWith('!')) { + const [command, ...args] = body.slice(1).split(' '); + await this.executeCommand(roomId, event, odUser, command.toLowerCase(), args.join(' ')); + return; + } + + // Check for natural language keywords + const keywordCommand = this.detectKeywordCommand(body); + if (keywordCommand) { + await this.executeCommand(roomId, event, odUser, keywordCommand, ''); + return; + } + } catch (error) { + this.logger.error(`Error handling message: ${error}`); + await this.sendReply( + roomId, + event, + '❌ Ein Fehler ist aufgetreten. Bitte versuche es erneut.' + ); + } + } + + private detectKeywordCommand(message: string): string | null { + const lowerMessage = message.toLowerCase().trim(); + + // Only check short messages for keywords + if (lowerMessage.length > 60) return null; + + for (const { keywords, command } of KEYWORD_COMMANDS) { + for (const keyword of keywords) { + if ( + lowerMessage === keyword || + lowerMessage.startsWith(keyword + ' ') || + lowerMessage.includes(keyword) + ) { + this.logger.log(`Detected keyword "${keyword}" -> command "${command}"`); + return command; + } + } + } + return null; + } + + private async executeCommand( + roomId: string, + event: any, + userId: string, + command: string, + args: string + ) { + switch (command) { + case 'help': + case 'hilfe': + await this.sendReply(roomId, event, HELP_TEXT); + break; + + case 'heute': + case 'today': + await this.handleTodayEvents(roomId, event, userId); + break; + + case 'morgen': + case 'tomorrow': + await this.handleTomorrowEvents(roomId, event, userId); + break; + + case 'woche': + case 'week': + await this.handleWeekEvents(roomId, event, userId); + break; + + case 'termine': + case 'events': + case 'upcoming': + await this.handleUpcomingEvents(roomId, event, userId); + break; + + case 'termin': + case 'event': + case 'neu': + case 'add': + await this.handleCreateEvent(roomId, event, userId, args); + break; + + case 'details': + case 'info': + await this.handleEventDetails(roomId, event, userId, args); + break; + + case 'löschen': + case 'delete': + case 'entfernen': + await this.handleDeleteEvent(roomId, event, userId, args); + break; + + case 'kalender': + case 'calendars': + await this.handleCalendars(roomId, event, userId); + break; + + case 'status': + await this.handleStatus(roomId, event, userId); + break; + + case 'pin': + await this.handlePinHelp(roomId, event); + break; + + default: + // Unknown command - ignore silently + break; + } + } + + private async handleTodayEvents(roomId: string, event: any, userId: string) { + const events = await this.calendarService.getTodayEvents(userId); + + if (events.length === 0) { + await this.sendReply( + roomId, + event, + '📭 Keine Termine für heute.\n\nErstelle einen mit `!termin Titel heute um 14:00`' + ); + return; + } + + const response = this.formatEventList('📅 **Termine heute:**', events); + await this.sendReply(roomId, event, response); + } + + private async handleTomorrowEvents(roomId: string, event: any, userId: string) { + const events = await this.calendarService.getTomorrowEvents(userId); + + if (events.length === 0) { + await this.sendReply( + roomId, + event, + '📭 Keine Termine für morgen.\n\nErstelle einen mit `!termin Titel morgen um 10:00`' + ); + return; + } + + const response = this.formatEventList('📅 **Termine morgen:**', events); + await this.sendReply(roomId, event, response); + } + + private async handleWeekEvents(roomId: string, event: any, userId: string) { + const events = await this.calendarService.getWeekEvents(userId); + + if (events.length === 0) { + await this.sendReply( + roomId, + event, + '📭 Keine Termine diese Woche.\n\nErstelle einen mit `!termin Titel am 20.02. um 14:00`' + ); + return; + } + + const response = this.formatEventList('📅 **Termine diese Woche:**', events); + await this.sendReply(roomId, event, response); + } + + private async handleUpcomingEvents(roomId: string, event: any, userId: string) { + const events = await this.calendarService.getUpcomingEvents(userId, 14); + + if (events.length === 0) { + await this.sendReply( + roomId, + event, + '📭 Keine anstehenden Termine.\n\nErstelle einen mit `!termin Meeting am 15.02. um 14:00`' + ); + return; + } + + const response = this.formatEventList('📅 **Anstehende Termine:**', events); + await this.sendReply(roomId, event, response); + } + + private async handleCreateEvent(roomId: string, event: any, userId: string, input: string) { + if (!input.trim()) { + await this.sendReply( + roomId, + event, + '❌ Bitte gib einen Termin an.\n\nBeispiele:\n• `!termin Meeting morgen um 14:00`\n• `!termin Geburtstag am 20.03. ganztägig`\n• `!termin Zahnarzt am 15.02. um 10:30`' + ); + return; + } + + const { title, startTime, endTime, isAllDay } = this.calendarService.parseEventInput(input); + + if (!startTime || !endTime) { + await this.sendReply( + roomId, + event, + '❌ Konnte Datum/Uhrzeit nicht erkennen.\n\nBeispiele:\n• `!termin Meeting morgen um 14:00`\n• `!termin Arzt am 15.02. um 10:00`\n• `!termin Urlaub am 01.03. ganztägig`' + ); + return; + } + + if (!title) { + await this.sendReply(roomId, event, '❌ Bitte gib einen Titel für den Termin an.'); + return; + } + + const calendarEvent = await this.calendarService.createEvent( + userId, + title, + startTime, + endTime, + { + isAllDay, + } + ); + + const timeStr = this.calendarService.formatEventTime(calendarEvent); + await this.sendReply(roomId, event, `✅ Termin erstellt: **${title}**\n📆 ${timeStr}`); + } + + private async handleEventDetails(roomId: string, event: any, userId: string, args: string) { + const eventNumber = parseInt(args.trim()); + + if (isNaN(eventNumber) || eventNumber < 1) { + await this.sendReply( + roomId, + event, + '❌ Bitte gib eine gültige Terminnummer an.\n\nBeispiel: `!details 1`' + ); + return; + } + + const calendarEvent = await this.calendarService.getEventByIndex(userId, eventNumber); + + if (!calendarEvent) { + await this.sendReply(roomId, event, `❌ Termin #${eventNumber} nicht gefunden.`); + return; + } + + const timeStr = this.calendarService.formatEventTime(calendarEvent); + let response = `📅 **${calendarEvent.title}**\n\n`; + response += `🕐 ${timeStr}\n`; + response += `📁 Kalender: ${calendarEvent.calendarName}\n`; + + if (calendarEvent.location) { + response += `📍 Ort: ${calendarEvent.location}\n`; + } + + if (calendarEvent.description) { + response += `\n📝 ${calendarEvent.description}`; + } + + await this.sendReply(roomId, event, response); + } + + private async handleDeleteEvent(roomId: string, event: any, userId: string, args: string) { + const eventNumber = parseInt(args.trim()); + + if (isNaN(eventNumber) || eventNumber < 1) { + await this.sendReply( + roomId, + event, + '❌ Bitte gib eine gültige Terminnummer an.\n\nBeispiel: `!löschen 1`' + ); + return; + } + + const deletedEvent = await this.calendarService.deleteEvent(userId, eventNumber); + + if (!deletedEvent) { + await this.sendReply(roomId, event, `❌ Termin #${eventNumber} nicht gefunden.`); + return; + } + + await this.sendReply(roomId, event, `🗑️ Gelöscht: ${deletedEvent.title}`); + } + + private async handleCalendars(roomId: string, event: any, userId: string) { + const calendars = await this.calendarService.getCalendars(userId); + + let response = '📁 **Deine Kalender:**\n\n'; + for (const calendar of calendars) { + response += `• ${calendar.name}\n`; + } + + await this.sendReply(roomId, event, response); + } + + private async handleStatus(roomId: string, event: any, userId: string) { + const events = await this.calendarService.getUpcomingEvents(userId, 7); + const todayEvents = await this.calendarService.getTodayEvents(userId); + + const response = `📊 **Status** + +• Termine heute: ${todayEvents.length} +• Termine nächste 7 Tage: ${events.length} + +Bot: ✅ Online`; + + await this.sendReply(roomId, event, response); + } + + private async handlePinHelp(roomId: string, event: any) { + try { + // Send help message + const helpEventId = await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: HELP_TEXT, + format: 'org.matrix.custom.html', + formatted_body: this.markdownToHtml(HELP_TEXT), + }); + + // Pin it + await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { + pinned: [helpEventId], + }); + + await this.sendReply(roomId, event, '📌 Hilfe wurde angepinnt!'); + } catch (error) { + this.logger.error('Failed to pin help:', error); + await this.sendReply( + roomId, + event, + '❌ Konnte Hilfe nicht anpinnen (fehlende Berechtigung?)' + ); + } + } + + private formatEventList(header: string, events: CalendarEvent[]): string { + let response = `${header}\n\n`; + + events.forEach((event, index) => { + const num = index + 1; + const timeStr = this.calendarService.formatEventTime(event); + response += `**${num}.** ${event.title}\n 🕐 ${timeStr}\n`; + }); + + response += `\n📋 Details: \`!details [Nr]\` | 🗑️ Löschen: \`!löschen [Nr]\``; + return response; + } + + private async sendReply(roomId: string, event: any, message: string) { + const reply = RichReply.createFor(roomId, event, message, this.markdownToHtml(message)); + reply.msgtype = 'm.text'; + await this.client.sendMessage(roomId, reply); + } + + private async sendWelcomeMessage(roomId: string, odUser: string) { + try { + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: WELCOME_TEXT, + format: 'org.matrix.custom.html', + formatted_body: this.markdownToHtml(WELCOME_TEXT), + }); + this.logger.log(`Sent welcome message to ${odUser} in ${roomId}`); + } catch (error) { + this.logger.error(`Failed to send welcome message: ${error}`); + } + } + + private async sendBotIntroduction(roomId: string) { + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: BOT_INTRODUCTION, + format: 'org.matrix.custom.html', + formatted_body: this.markdownToHtml(BOT_INTRODUCTION), + }); + + // Try to pin the help message + try { + const helpEventId = await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: HELP_TEXT, + format: 'org.matrix.custom.html', + formatted_body: this.markdownToHtml(HELP_TEXT), + }); + + await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { + pinned: [helpEventId], + }); + this.logger.log(`Pinned help message in ${roomId}`); + } catch (error) { + this.logger.debug(`Could not pin help (might lack permissions): ${error}`); + } + } + + private markdownToHtml(text: string): string { + return text + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/\n/g, '
'); + } +} diff --git a/services/matrix-calendar-bot/src/calendar/calendar.module.ts b/services/matrix-calendar-bot/src/calendar/calendar.module.ts new file mode 100644 index 000000000..d5859c9b2 --- /dev/null +++ b/services/matrix-calendar-bot/src/calendar/calendar.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CalendarService } from './calendar.service'; + +@Module({ + providers: [CalendarService], + exports: [CalendarService], +}) +export class CalendarModule {} diff --git a/services/matrix-calendar-bot/src/calendar/calendar.service.ts b/services/matrix-calendar-bot/src/calendar/calendar.service.ts new file mode 100644 index 000000000..8dc5bb5f5 --- /dev/null +++ b/services/matrix-calendar-bot/src/calendar/calendar.service.ts @@ -0,0 +1,321 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'fs'; +import * as path from 'path'; + +export interface CalendarEvent { + id: string; + title: string; + description: string | null; + location: string | null; + startTime: string; // ISO datetime + endTime: string; // ISO datetime + isAllDay: boolean; + calendarId: string; + calendarName: string; + createdAt: string; + userId: string; // Matrix user ID +} + +export interface Calendar { + id: string; + name: string; + color: string; + userId: string; +} + +interface CalendarData { + events: CalendarEvent[]; + calendars: Calendar[]; +} + +@Injectable() +export class CalendarService implements OnModuleInit { + private readonly logger = new Logger(CalendarService.name); + private data: CalendarData = { events: [], calendars: [] }; + private dataPath: string; + + constructor(private configService: ConfigService) { + const storagePath = this.configService.get( + 'matrix.storagePath', + './data/bot-storage.json' + ); + this.dataPath = storagePath.replace('bot-storage.json', 'calendar-data.json'); + } + + async onModuleInit() { + await this.loadData(); + } + + private async loadData(): Promise { + try { + const dir = path.dirname(this.dataPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (fs.existsSync(this.dataPath)) { + const content = fs.readFileSync(this.dataPath, 'utf-8'); + this.data = JSON.parse(content); + this.logger.log( + `Loaded ${this.data.events.length} events, ${this.data.calendars.length} calendars` + ); + } else { + this.data = { events: [], calendars: [] }; + await this.saveData(); + this.logger.log('Created new calendar data file'); + } + } catch (error) { + this.logger.error('Failed to load calendar data:', error); + this.data = { events: [], calendars: [] }; + } + } + + private async saveData(): Promise { + try { + fs.writeFileSync(this.dataPath, JSON.stringify(this.data, null, 2)); + } catch (error) { + this.logger.error('Failed to save calendar data:', error); + } + } + + private generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substr(2); + } + + private ensureDefaultCalendar(userId: string): Calendar { + let calendar = this.data.calendars.find((c) => c.userId === userId); + if (!calendar) { + calendar = { + id: this.generateId(), + name: 'Mein Kalender', + color: '#3B82F6', + userId, + }; + this.data.calendars.push(calendar); + this.saveData(); + } + return calendar; + } + + // Event operations + + async createEvent( + userId: string, + title: string, + startTime: Date, + endTime: Date, + options?: Partial + ): Promise { + const calendar = this.ensureDefaultCalendar(userId); + + const event: CalendarEvent = { + id: this.generateId(), + title, + description: options?.description || null, + location: options?.location || null, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + isAllDay: options?.isAllDay || false, + calendarId: calendar.id, + calendarName: calendar.name, + createdAt: new Date().toISOString(), + userId, + }; + + this.data.events.push(event); + await this.saveData(); + this.logger.log(`Created event "${title}" for user ${userId}`); + return event; + } + + async getTodayEvents(userId: string): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + return this.getEventsInRange(userId, today, tomorrow); + } + + async getTomorrowEvents(userId: string): Promise { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + const dayAfter = new Date(tomorrow); + dayAfter.setDate(dayAfter.getDate() + 1); + + return this.getEventsInRange(userId, tomorrow, dayAfter); + } + + async getWeekEvents(userId: string): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const weekEnd = new Date(today); + weekEnd.setDate(weekEnd.getDate() + 7); + + return this.getEventsInRange(userId, today, weekEnd); + } + + async getUpcomingEvents(userId: string, days: number = 7): Promise { + const now = new Date(); + const endDate = new Date(now); + endDate.setDate(endDate.getDate() + days); + + return this.getEventsInRange(userId, now, endDate); + } + + private getEventsInRange(userId: string, start: Date, end: Date): CalendarEvent[] { + return this.data.events + .filter((e) => { + if (e.userId !== userId) return false; + const eventStart = new Date(e.startTime); + const eventEnd = new Date(e.endTime); + // Event overlaps with range + return eventStart < end && eventEnd > start; + }) + .sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); + } + + async getEventByIndex(userId: string, index: number): Promise { + const events = await this.getUpcomingEvents(userId, 30); + if (index < 1 || index > events.length) { + return null; + } + return events[index - 1]; + } + + async deleteEvent(userId: string, eventIndex: number): Promise { + const events = await this.getUpcomingEvents(userId, 30); + if (eventIndex < 1 || eventIndex > events.length) { + return null; + } + + const event = events[eventIndex - 1]; + this.data.events = this.data.events.filter((e) => e.id !== event.id); + await this.saveData(); + this.logger.log(`Deleted event "${event.title}" for user ${userId}`); + return event; + } + + // Calendar operations + + async getCalendars(userId: string): Promise { + this.ensureDefaultCalendar(userId); + return this.data.calendars.filter((c) => c.userId === userId); + } + + // Parse natural language date/time input + parseEventInput(input: string): { + title: string; + startTime: Date | null; + endTime: Date | null; + isAllDay: boolean; + } { + let title = input; + let startTime: Date | null = null; + let endTime: Date | null = null; + let isAllDay = false; + + const now = new Date(); + + // Check for "ganztägig" (all-day) + if (/ganztägig/i.test(title)) { + isAllDay = true; + title = title.replace(/ganztägig/gi, '').trim(); + } + + // Parse date patterns + // "am DD.MM." or "am DD.MM.YYYY" + const dateMatch = title.match(/am\s+(\d{1,2})\.(\d{1,2})\.?(\d{4})?/i); + // "heute", "morgen", "übermorgen" + const relativeMatch = title.match(/(heute|morgen|übermorgen)/i); + // Time: "um HH:MM" or "um HH Uhr" + const timeMatch = title.match(/um\s+(\d{1,2})[:.]?(\d{2})?\s*(uhr)?/i); + + if (dateMatch) { + const day = parseInt(dateMatch[1]); + const month = parseInt(dateMatch[2]) - 1; + const year = dateMatch[3] ? parseInt(dateMatch[3]) : now.getFullYear(); + + startTime = new Date(year, month, day); + + // If date is in the past this year, assume next year + if (startTime < now && !dateMatch[3]) { + startTime.setFullYear(startTime.getFullYear() + 1); + } + + title = title.replace(/am\s+\d{1,2}\.\d{1,2}\.?\d{0,4}/i, '').trim(); + } else if (relativeMatch) { + const relative = relativeMatch[1].toLowerCase(); + startTime = new Date(); + startTime.setHours(0, 0, 0, 0); + + if (relative === 'morgen') { + startTime.setDate(startTime.getDate() + 1); + } else if (relative === 'übermorgen') { + startTime.setDate(startTime.getDate() + 2); + } + + title = title.replace(/(heute|morgen|übermorgen)/i, '').trim(); + } + + if (timeMatch && startTime) { + const hours = parseInt(timeMatch[1]); + const minutes = timeMatch[2] ? parseInt(timeMatch[2]) : 0; + + startTime.setHours(hours, minutes, 0, 0); + isAllDay = false; + + title = title.replace(/um\s+\d{1,2}[:.]?\d{0,2}\s*(uhr)?/i, '').trim(); + } else if (startTime && !isAllDay) { + // Default to 9:00 if no time specified + startTime.setHours(9, 0, 0, 0); + } + + // Set end time (1 hour later for timed events, end of day for all-day) + if (startTime) { + endTime = new Date(startTime); + if (isAllDay) { + endTime.setHours(23, 59, 59, 999); + } else { + endTime.setHours(endTime.getHours() + 1); + } + } + + // Clean up title + title = title.replace(/\s+/g, ' ').trim(); + + return { title, startTime, endTime, isAllDay }; + } + + // Format date for display + formatEventTime(event: CalendarEvent): string { + const start = new Date(event.startTime); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + const eventDate = new Date(start.getFullYear(), start.getMonth(), start.getDate()); + + let dateStr: string; + if (eventDate.getTime() === today.getTime()) { + dateStr = 'Heute'; + } else if (eventDate.getTime() === tomorrow.getTime()) { + dateStr = 'Morgen'; + } else { + dateStr = start.toLocaleDateString('de-DE', { + weekday: 'short', + day: '2-digit', + month: '2-digit', + }); + } + + if (event.isAllDay) { + return `${dateStr} (ganztägig)`; + } + + const timeStr = start.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + return `${dateStr}, ${timeStr}`; + } +} diff --git a/services/matrix-calendar-bot/src/config/configuration.ts b/services/matrix-calendar-bot/src/config/configuration.ts new file mode 100644 index 000000000..25313fe49 --- /dev/null +++ b/services/matrix-calendar-bot/src/config/configuration.ts @@ -0,0 +1,61 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3315', 10), + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN || '', + allowedRooms: (process.env.MATRIX_ALLOWED_ROOMS || '').split(',').filter(Boolean), + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + calendar: { + apiUrl: process.env.CALENDAR_API_URL || 'http://localhost:3016/api/v1', + }, +}); + +export const HELP_TEXT = `📅 **Calendar Bot - Hilfe** + +**Termine anzeigen:** +• \`!heute\` - Termine für heute +• \`!morgen\` - Termine für morgen +• \`!woche\` - Termine diese Woche +• \`!termine\` - Nächste 7 Tage + +**Termine erstellen:** +• \`!termin [Titel] am [Datum] um [Uhrzeit]\` +• \`!termin Meeting am 15.02. um 14:00\` +• \`!termin Zahnarzt morgen um 10:30\` +• \`!termin Geburtstag am 20.03. ganztägig\` + +**Termine verwalten:** +• \`!details [Nr]\` - Details zu einem Termin +• \`!löschen [Nr]\` - Termin löschen + +**Kalender:** +• \`!kalender\` - Deine Kalender anzeigen + +**Sonstiges:** +• \`!status\` - Verbindungsstatus +• \`!help\` oder \`hilfe\` - Diese Hilfe + +**Natürliche Sprache:** +Du kannst auch "was steht heute an?", "termine morgen" oder "zeige kalender" schreiben.`; + +export const WELCOME_TEXT = `👋 **Willkommen beim Calendar Bot!** + +Ich helfe dir, deine Termine zu verwalten. Hier sind die wichtigsten Befehle: + +• \`!heute\` - Heutige Termine +• \`!termin Meeting morgen um 14:00\` - Termin erstellen +• \`!woche\` - Wochenübersicht + +Schreibe \`!help\` oder einfach "hilfe" für alle Befehle.`; + +export const BOT_INTRODUCTION = `📅 **Hallo! Ich bin der Calendar Bot.** + +Ich bin jetzt diesem Raum beigetreten und kann dir bei der Terminverwaltung helfen. + +**Schnellstart:** +• \`!heute\` - Was steht heute an? +• \`!termin Arzt morgen um 10:00\` - Termin erstellen +• \`!woche\` - Wochenübersicht + +Schreibe \`!help\` für alle Befehle!`; diff --git a/services/matrix-calendar-bot/src/health.controller.ts b/services/matrix-calendar-bot/src/health.controller.ts new file mode 100644 index 000000000..e9e747e66 --- /dev/null +++ b/services/matrix-calendar-bot/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'matrix-calendar-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/matrix-calendar-bot/src/main.ts b/services/matrix-calendar-bot/src/main.ts new file mode 100644 index 000000000..0fd19cb02 --- /dev/null +++ b/services/matrix-calendar-bot/src/main.ts @@ -0,0 +1,17 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { ConfigService } from '@nestjs/config'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + const configService = app.get(ConfigService); + const port = configService.get('port', 3315); + + await app.listen(port); + logger.log(`Calendar Bot is running on port ${port}`); +} + +bootstrap(); diff --git a/services/matrix-calendar-bot/tsconfig.build.json b/services/matrix-calendar-bot/tsconfig.build.json new file mode 100644 index 000000000..4491981e0 --- /dev/null +++ b/services/matrix-calendar-bot/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/services/matrix-calendar-bot/tsconfig.json b/services/matrix-calendar-bot/tsconfig.json new file mode 100644 index 000000000..b439390d0 --- /dev/null +++ b/services/matrix-calendar-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + } +} diff --git a/services/matrix-nutriphi-bot/.env.example b/services/matrix-nutriphi-bot/.env.example new file mode 100644 index 000000000..e176186a4 --- /dev/null +++ b/services/matrix-nutriphi-bot/.env.example @@ -0,0 +1,20 @@ +# Server +PORT=3316 +NODE_ENV=development + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx_your_bot_token +MATRIX_ALLOWED_ROOMS=#nutriphi:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# NutriPhi Backend +NUTRIPHI_BACKEND_URL=http://localhost:3023 +NUTRIPHI_API_PREFIX=/api/v1 + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 + +# Development bypass (optional) +DEV_BYPASS_AUTH=false +DEV_USER_ID= diff --git a/services/matrix-nutriphi-bot/CLAUDE.md b/services/matrix-nutriphi-bot/CLAUDE.md new file mode 100644 index 000000000..0b9056ec0 --- /dev/null +++ b/services/matrix-nutriphi-bot/CLAUDE.md @@ -0,0 +1,156 @@ +# Matrix NutriPhi Bot - Claude Code Guidelines + +## Overview + +Matrix NutriPhi Bot is a Matrix chat bot for AI-powered nutrition tracking. It integrates with the NutriPhi backend to analyze meal photos and text descriptions, track daily nutrition, and provide personalized recommendations. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Backend**: NutriPhi API (port 3023) +- **Auth**: Mana Core Auth (JWT) + +## Commands + +```bash +# Development +pnpm install +pnpm start:dev # Start with hot reload + +# Build +pnpm build # Production build + +# Type check +pnpm type-check # Check TypeScript types +``` + +## Project Structure + +``` +services/matrix-nutriphi-bot/ +├── src/ +│ ├── main.ts # Application entry point (port 3316) +│ ├── app.module.ts # Root module +│ ├── health.controller.ts # Health check endpoint +│ ├── config/ +│ │ └── configuration.ts # Configuration & help messages +│ ├── bot/ +│ │ ├── bot.module.ts +│ │ └── matrix.service.ts # Matrix client & command handlers +│ ├── nutriphi/ +│ │ ├── nutriphi.module.ts +│ │ └── nutriphi.service.ts # NutriPhi API client +│ └── session/ +│ ├── session.module.ts +│ └── session.service.ts # User session & auth management +├── Dockerfile +└── package.json +``` + +## Bot Commands + +| Command | Aliases | Description | +|---------|---------|-------------| +| `!help` | hilfe, help | Show help message | +| `!login email pass` | - | Login to NutriPhi | +| `!logout` | - | Logout | +| `!analyze [text]` | - | Analyze photo or text | +| `!today` | heute | Daily summary | +| `!week` | woche | Weekly stats | +| `!goals` | ziele | Show goals | +| `!setgoals cal pro carb fat` | - | Set goals | +| `!favorites` | favoriten | List favorites | +| `!tips` | tipps | AI recommendations | +| `!status` | - | Bot status | + +## Image Analysis Flow + +1. User sends image to room +2. Bot stores image URL: "Bild empfangen!" +3. User sends `!analyze` or `!analyze Beschreibung` +4. Bot downloads image, sends to NutriPhi API +5. Bot displays nutrition results + +## Environment Variables + +```env +# Server +PORT=3316 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#nutriphi:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# NutriPhi Backend +NUTRIPHI_BACKEND_URL=http://localhost:3023 +NUTRIPHI_API_PREFIX=/api/v1 + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 + +# Development bypass (optional) +DEV_BYPASS_AUTH=false +DEV_USER_ID= +``` + +## Docker + +```bash +# Build locally +docker build -f services/matrix-nutriphi-bot/Dockerfile -t matrix-nutriphi-bot services/matrix-nutriphi-bot + +# Run +docker run -p 3315:3315 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -e NUTRIPHI_BACKEND_URL=http://nutriphi-backend:3023 \ + -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ + -v matrix-nutriphi-bot-data:/app/data \ + matrix-nutriphi-bot +``` + +## Health Check + +```bash +curl http://localhost:3316/health +``` + +## Getting a Matrix Access Token + +```bash +# Create bot user first, then login +curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "m.login.password", + "user": "nutriphi-bot", + "password": "your-password" + }' + +# Response contains: {"access_token": "syt_xxx", ...} +``` + +## Authentication Flow + +1. User sends `!login email password` +2. Bot calls mana-core-auth `/api/v1/auth/login` +3. JWT token stored in session (in-memory) +4. Token used for all NutriPhi API calls +5. Token expires after 7 days (re-login required) + +## NutriPhi API Endpoints Used + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/health` | GET | Health check | +| `/api/v1/analysis/photo` | POST | Analyze photo | +| `/api/v1/analysis/text` | POST | Analyze text | +| `/api/v1/meals` | POST | Create meal | +| `/api/v1/goals` | GET/POST | User goals | +| `/api/v1/stats/daily` | GET | Daily summary | +| `/api/v1/stats/weekly` | GET | Weekly stats | +| `/api/v1/favorites` | GET | List favorites | +| `/api/v1/recommendations` | GET | AI tips | diff --git a/services/matrix-nutriphi-bot/Dockerfile b/services/matrix-nutriphi-bot/Dockerfile new file mode 100644 index 000000000..03e956aec --- /dev/null +++ b/services/matrix-nutriphi-bot/Dockerfile @@ -0,0 +1,47 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files (exclude pnpm-lock.yaml to use npm) +COPY package.json ./ + +# Install dependencies using npm (more compatible with standard tooling) +RUN npm install + +# Copy source +COPY . . + +# Build using TypeScript +RUN rm -rf dist && npx tsc -p tsconfig.build.json + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# Create data directory for bot storage +RUN mkdir -p /app/data + +# Copy package files +COPY package.json ./ + +# Install production dependencies only +RUN npm install --omit=dev + +# Copy built files +COPY --from=builder /app/dist ./dist + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nestjs +RUN chown -R nestjs:nodejs /app +USER nestjs + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3316/health || exit 1 + +EXPOSE 3316 + +CMD ["node", "dist/main.js"] diff --git a/services/matrix-nutriphi-bot/nest-cli.json b/services/matrix-nutriphi-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/matrix-nutriphi-bot/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/matrix-nutriphi-bot/package.json b/services/matrix-nutriphi-bot/package.json new file mode 100644 index 000000000..8e63a104a --- /dev/null +++ b/services/matrix-nutriphi-bot/package.json @@ -0,0 +1,44 @@ +{ + "name": "@manacore/matrix-nutriphi-bot", + "version": "1.0.0", + "description": "Matrix bot for NutriPhi - AI-powered nutrition tracking via Matrix", + "private": true, + "license": "MIT", + "pnpm": { + "neverBuiltDependencies": [ + "@matrix-org/matrix-sdk-crypto-nodejs" + ], + "overrides": { + "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" + } + }, + "overrides": { + "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" + }, + "scripts": { + "prebuild": "rm -rf dist || true", + "build": "tsc -p tsconfig.build.json", + "format": "prettier --write \"src/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "matrix-bot-sdk": "^0.7.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } +} diff --git a/services/matrix-nutriphi-bot/src/app.module.ts b/services/matrix-nutriphi-bot/src/app.module.ts new file mode 100644 index 000000000..52d5de4a4 --- /dev/null +++ b/services/matrix-nutriphi-bot/src/app.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { BotModule } from './bot/bot.module'; +import { HealthController } from './health.controller'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-nutriphi-bot/src/bot/bot.module.ts b/services/matrix-nutriphi-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..5bcbc59a9 --- /dev/null +++ b/services/matrix-nutriphi-bot/src/bot/bot.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { NutriPhiModule } from '../nutriphi/nutriphi.module'; +import { SessionModule } from '../session/session.module'; + +@Module({ + imports: [NutriPhiModule, SessionModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-nutriphi-bot/src/bot/matrix.service.ts b/services/matrix-nutriphi-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..4e29c3d1e --- /dev/null +++ b/services/matrix-nutriphi-bot/src/bot/matrix.service.ts @@ -0,0 +1,706 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + RichConsoleLogger, + LogService, + LogLevel, +} from 'matrix-bot-sdk'; +import { + NutriPhiService, + AIAnalysisResult, + DailySummary, + WeeklyStats, +} from '../nutriphi/nutriphi.service'; +import { SessionService } from '../session/session.service'; +import { HELP_MESSAGE, MEAL_TYPE_LABELS } from '../config/configuration'; + +// Natural language keywords that trigger commands (German + English) +const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ + { keywords: ['hilfe', 'help', 'was kannst du', 'befehle', 'commands'], command: 'help' }, + { keywords: ['heute', 'today', 'tages', 'tagesübersicht'], command: 'today' }, + { keywords: ['woche', 'week', 'wochen', 'wochenübersicht'], command: 'week' }, + { keywords: ['ziele', 'goals', 'meine ziele'], command: 'goals' }, + { keywords: ['favoriten', 'favorites', 'lieblings'], command: 'favorites' }, + { keywords: ['tipps', 'tips', 'empfehlungen', 'ratschläge'], command: 'tips' }, + { keywords: ['status', 'verbindung'], command: 'status' }, +]; + +@Injectable() +export class MatrixService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MatrixService.name); + private client!: MatrixClient; + private readonly allowedRooms: string[]; + private botUserId: string = ''; + + constructor( + private configService: ConfigService, + private nutriphiService: NutriPhiService, + private sessionService: SessionService + ) { + this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; + } + + async onModuleInit() { + const homeserverUrl = this.configService.get('matrix.homeserverUrl'); + const accessToken = this.configService.get('matrix.accessToken'); + const storagePath = this.configService.get('matrix.storagePath'); + + if (!accessToken) { + this.logger.error('MATRIX_ACCESS_TOKEN is required'); + return; + } + + // Setup logging + LogService.setLogger(new RichConsoleLogger()); + LogService.setLevel(LogLevel.INFO); + + // Storage for sync token persistence + const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); + + // Create Matrix client + this.client = new MatrixClient(homeserverUrl!, accessToken, storage); + + // Auto-join rooms when invited + this.client.on('room.invite', async (roomId: string) => { + this.logger.log(`Invited to room ${roomId}, joining...`); + await this.client.joinRoom(roomId); + + setTimeout(async () => { + try { + await this.sendBotIntroduction(roomId); + } catch (error) { + this.logger.error(`Failed to send introduction to ${roomId}:`, error); + } + }, 2000); + }); + + // Get bot's user ID + this.botUserId = await this.client.getUserId(); + this.logger.log(`Bot user ID: ${this.botUserId}`); + + // Setup message handler + this.client.on('room.message', this.handleRoomMessage.bind(this)); + + // Start the client + await this.client.start(); + this.logger.log('Matrix NutriPhi Bot started successfully'); + } + + async onModuleDestroy() { + if (this.client) { + await this.client.stop(); + this.logger.log('Matrix bot stopped'); + } + } + + private async sendBotIntroduction(roomId: string) { + const introText = `**NutriPhi Bot - KI-Ernahrungsassistent** + +Analysiere deine Mahlzeiten mit KI und tracke deine Ernahrung! + +**Quick Start:** +1. \`!login email passwort\` - Anmelden +2. Sende ein Foto deiner Mahlzeit +3. \`!analyze\` - Nahrwerte erhalten + +Sag "hilfe" fur alle Befehle!`; + + await this.sendMessage(roomId, introText); + } + + private isRoomAllowed(roomId: string): boolean { + if (this.allowedRooms.length === 0) return true; + return this.allowedRooms.some((allowed) => roomId === allowed || roomId.includes(allowed)); + } + + private async handleRoomMessage(roomId: string, event: any) { + // Ignore messages from self + if (event.sender === this.botUserId) return; + + // Check if room is allowed + if (!this.isRoomAllowed(roomId)) { + this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`); + return; + } + + const content = event.content as { + msgtype?: string; + body?: string; + url?: string; + info?: { mimetype?: string }; + }; + + // Handle image messages + if (content.msgtype === 'm.image' && content.url) { + this.sessionService.setPendingImage( + event.sender, + content.url, + content.info?.mimetype || 'image/png' + ); + this.logger.log(`Image received from ${event.sender}`); + await this.sendMessage( + roomId, + `Bild empfangen! Nutze jetzt \`!analyze\` um es zu analysieren, oder \`!analyze Beschreibung\` um zusatzlichen Kontext zu geben.` + ); + return; + } + + // Only handle text messages + if (content.msgtype !== 'm.text') return; + + const body = content.body; + if (!body) return; + + this.logger.log(`Message from ${event.sender} in ${roomId}: ${body.substring(0, 50)}...`); + + // Handle commands with ! prefix + if (body.startsWith('!')) { + await this.handleCommand(roomId, event.sender, body); + return; + } + + // Check for natural language keywords + const keywordCommand = this.detectKeywordCommand(body); + if (keywordCommand) { + await this.handleCommand(roomId, event.sender, `!${keywordCommand}`); + return; + } + + // Don't respond to random messages - only commands + } + + private detectKeywordCommand(message: string): string | null { + const lowerMessage = message.toLowerCase().trim(); + + // Only match if the message is short + if (lowerMessage.length > 50) return null; + + for (const { keywords, command } of KEYWORD_COMMANDS) { + for (const keyword of keywords) { + if (lowerMessage === keyword || lowerMessage.startsWith(keyword + ' ')) { + this.logger.log(`Detected keyword "${keyword}" -> command "${command}"`); + return command; + } + } + } + return null; + } + + private async handleCommand(roomId: string, sender: string, body: string) { + const [command, ...args] = body.slice(1).split(' '); + const argString = args.join(' '); + + switch (command.toLowerCase()) { + case 'help': + case 'start': + await this.sendHelp(roomId); + break; + + case 'login': + await this.handleLogin(roomId, sender, args); + break; + + case 'logout': + this.sessionService.logout(sender); + await this.sendMessage(roomId, 'Du wurdest abgemeldet.'); + break; + + case 'analyze': + await this.handleAnalyze(roomId, sender, argString); + break; + + case 'today': + await this.handleToday(roomId, sender); + break; + + case 'week': + await this.handleWeek(roomId, sender); + break; + + case 'goals': + await this.handleGoals(roomId, sender); + break; + + case 'setgoals': + await this.handleSetGoals(roomId, sender, args); + break; + + case 'favorites': + await this.handleFavorites(roomId, sender); + break; + + case 'tips': + await this.handleTips(roomId, sender); + break; + + case 'status': + await this.handleStatus(roomId, sender); + break; + + case 'pin': + await this.pinHelpMessage(roomId); + break; + + default: + await this.sendMessage( + roomId, + `Unbekannter Befehl: !${command}\n\nSag "hilfe" fur alle Befehle.` + ); + } + } + + private async sendHelp(roomId: string) { + await this.sendMessage(roomId, HELP_MESSAGE); + } + + private async handleLogin(roomId: string, sender: string, args: string[]) { + if (args.length < 2) { + await this.sendMessage( + roomId, + `**Verwendung:** \`!login email passwort\`\n\nBeispiel: \`!login nutzer@example.com meinpasswort\`` + ); + return; + } + + const [email, password] = args; + + await this.sendMessage(roomId, 'Anmeldung lauft...'); + + const result = await this.sessionService.login(sender, email, password); + + if (result.success) { + await this.sendMessage( + roomId, + `Erfolgreich angemeldet!\n\nDu kannst jetzt Fotos analysieren und deine Ernahrung tracken.` + ); + } else { + await this.sendMessage(roomId, `Anmeldung fehlgeschlagen: ${result.error}`); + } + } + + private async handleAnalyze(roomId: string, sender: string, description: string) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage( + roomId, + `Du bist nicht angemeldet. Nutze \`!login email passwort\` um dich anzumelden.` + ); + return; + } + + const pendingImage = this.sessionService.getPendingImage(sender); + + // If no image and no description, show help + if (!pendingImage && !description.trim()) { + await this.sendMessage( + roomId, + `**Verwendung:**\n- Sende ein Foto, dann \`!analyze\`\n- Oder: \`!analyze Spaghetti mit Tomatensauce\`` + ); + return; + } + + await this.client.setTyping(roomId, true, 60000); + + try { + let result: AIAnalysisResult; + + if (pendingImage) { + // Analyze image + await this.sendMessage(roomId, 'Analysiere Bild...'); + const imageData = await this.downloadMatrixImage(pendingImage.url); + result = await this.nutriphiService.analyzePhoto(imageData, pendingImage.mimeType, token); + this.sessionService.clearPendingImage(sender); + } else { + // Analyze text + await this.sendMessage(roomId, `Analysiere: "${description}"...`); + result = await this.nutriphiService.analyzeText(description, token); + } + + await this.client.setTyping(roomId, false); + + // Format and send result + const response = this.formatAnalysisResult(result); + await this.sendMessage(roomId, response); + } catch (error) { + await this.client.setTyping(roomId, false); + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler bei der Analyse: ${errorMsg}`); + } + } + + private formatAnalysisResult(result: AIAnalysisResult): string { + const { foods, totalNutrition, confidence, warnings, suggestions } = result; + + let text = `**Mahlzeit analysiert** (Konfidenz: ${Math.round(confidence * 100)}%)\n\n`; + + if (foods.length > 0) { + text += '**Erkannte Lebensmittel:**\n'; + for (const food of foods) { + text += `- ${food.name} (${food.quantity}) - ${food.calories} kcal\n`; + } + text += '\n'; + } + + text += `**Nahrwerte:**\n`; + text += `- Kalorien: ${Math.round(totalNutrition.calories)} kcal\n`; + text += `- Protein: ${Math.round(totalNutrition.protein)}g\n`; + text += `- Kohlenhydrate: ${Math.round(totalNutrition.carbohydrates)}g\n`; + text += `- Fett: ${Math.round(totalNutrition.fat)}g\n`; + text += `- Ballaststoffe: ${Math.round(totalNutrition.fiber)}g\n`; + + if (warnings && warnings.length > 0) { + text += `\n**Hinweise:**\n`; + for (const warning of warnings) { + text += `- ${warning}\n`; + } + } + + if (suggestions && suggestions.length > 0) { + text += `\n**Vorschlage:**\n`; + for (const suggestion of suggestions) { + text += `- ${suggestion}\n`; + } + } + + return text; + } + + private async handleToday(roomId: string, sender: string) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + await this.client.setTyping(roomId, true, 10000); + + try { + const today = new Date().toISOString().split('T')[0]; + const summary = await this.nutriphiService.getDailySummary(today, token); + + await this.client.setTyping(roomId, false); + await this.sendMessage(roomId, this.formatDailySummary(summary)); + } catch (error) { + await this.client.setTyping(roomId, false); + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private formatDailySummary(summary: DailySummary): string { + const dateStr = new Date(summary.date).toLocaleDateString('de-DE', { + weekday: 'long', + day: 'numeric', + month: 'long', + }); + + let text = `**Tages-Zusammenfassung - ${dateStr}**\n\n`; + + const { progress } = summary; + text += `**Kalorien:** ${Math.round(progress.calories.current)} / ${progress.calories.target} kcal (${Math.round(progress.calories.percentage)}%)\n`; + + if (progress.protein) { + text += `**Protein:** ${Math.round(progress.protein.current)}g / ${progress.protein.target}g (${Math.round(progress.protein.percentage)}%)\n`; + } + if (progress.carbs) { + text += `**Kohlenhydrate:** ${Math.round(progress.carbs.current)}g / ${progress.carbs.target}g (${Math.round(progress.carbs.percentage)}%)\n`; + } + if (progress.fat) { + text += `**Fett:** ${Math.round(progress.fat.current)}g / ${progress.fat.target}g (${Math.round(progress.fat.percentage)}%)\n`; + } + + if (summary.meals.length > 0) { + text += `\n**Mahlzeiten (${summary.meals.length}):**\n`; + for (const meal of summary.meals) { + const mealLabel = MEAL_TYPE_LABELS[meal.mealType] || meal.mealType; + const calories = meal.nutrition?.calories + ? ` - ${Math.round(meal.nutrition.calories)} kcal` + : ''; + text += `- ${mealLabel}: ${meal.description}${calories}\n`; + } + } else { + text += `\n_Noch keine Mahlzeiten heute._`; + } + + return text; + } + + private async handleWeek(roomId: string, sender: string) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + await this.client.setTyping(roomId, true, 10000); + + try { + const today = new Date().toISOString().split('T')[0]; + const stats = await this.nutriphiService.getWeeklyStats(today, token); + + await this.client.setTyping(roomId, false); + await this.sendMessage(roomId, this.formatWeeklyStats(stats)); + } catch (error) { + await this.client.setTyping(roomId, false); + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private formatWeeklyStats(stats: WeeklyStats): string { + const startStr = new Date(stats.startDate).toLocaleDateString('de-DE', { + day: 'numeric', + month: 'short', + }); + const endStr = new Date(stats.endDate).toLocaleDateString('de-DE', { + day: 'numeric', + month: 'short', + }); + + let text = `**Wochen-Statistik (${startStr} - ${endStr})**\n\n`; + + text += `**Durchschnittswerte:**\n`; + text += `- Kalorien: ${Math.round(stats.averages.calories)} kcal/Tag\n`; + text += `- Protein: ${Math.round(stats.averages.protein)}g/Tag\n`; + text += `- Kohlenhydrate: ${Math.round(stats.averages.carbs)}g/Tag\n`; + text += `- Fett: ${Math.round(stats.averages.fat)}g/Tag\n\n`; + + text += `**Tage:**\n`; + for (const day of stats.days) { + const dayStr = new Date(day.date).toLocaleDateString('de-DE', { + weekday: 'short', + day: 'numeric', + }); + const goalIcon = day.goalsMet ? ' ' : ''; + text += `- ${dayStr}: ${Math.round(day.totalCalories)} kcal, ${day.mealCount} Mahlzeiten${goalIcon}\n`; + } + + return text; + } + + private async handleGoals(roomId: string, sender: string) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + try { + const goals = await this.nutriphiService.getGoals(token); + + if (!goals) { + await this.sendMessage( + roomId, + `Du hast noch keine Ziele gesetzt.\n\nNutze \`!setgoals kalorien protein carbs fett\`\nBeispiel: \`!setgoals 2000 80 250 65\`` + ); + return; + } + + let text = `**Deine Tagesziele:**\n\n`; + text += `- Kalorien: ${goals.dailyCalories} kcal\n`; + if (goals.dailyProtein) text += `- Protein: ${goals.dailyProtein}g\n`; + if (goals.dailyCarbs) text += `- Kohlenhydrate: ${goals.dailyCarbs}g\n`; + if (goals.dailyFat) text += `- Fett: ${goals.dailyFat}g\n`; + + await this.sendMessage(roomId, text); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleSetGoals(roomId: string, sender: string, args: string[]) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + if (args.length < 1) { + await this.sendMessage( + roomId, + `**Verwendung:** \`!setgoals kalorien [protein] [carbs] [fett]\`\n\nBeispiel: \`!setgoals 2000 80 250 65\`` + ); + return; + } + + const calories = parseInt(args[0], 10); + const protein = args[1] ? parseInt(args[1], 10) : undefined; + const carbs = args[2] ? parseInt(args[2], 10) : undefined; + const fat = args[3] ? parseInt(args[3], 10) : undefined; + + if (isNaN(calories) || calories < 500 || calories > 10000) { + await this.sendMessage( + roomId, + `Ungiultige Kalorienzahl. Bitte eine Zahl zwischen 500 und 10000 angeben.` + ); + return; + } + + try { + await this.nutriphiService.setGoals( + { + dailyCalories: calories, + dailyProtein: protein, + dailyCarbs: carbs, + dailyFat: fat, + }, + token + ); + + let text = `**Ziele gesetzt:**\n`; + text += `- Kalorien: ${calories} kcal\n`; + if (protein) text += `- Protein: ${protein}g\n`; + if (carbs) text += `- Kohlenhydrate: ${carbs}g\n`; + if (fat) text += `- Fett: ${fat}g\n`; + + await this.sendMessage(roomId, text); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleFavorites(roomId: string, sender: string) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + try { + const favorites = await this.nutriphiService.getFavorites(token); + + if (favorites.length === 0) { + await this.sendMessage(roomId, `Du hast noch keine Favoriten gespeichert.`); + return; + } + + let text = `**Deine Favoriten (${favorites.length}):**\n\n`; + for (const fav of favorites) { + text += `- **${fav.name}** (${fav.usageCount}x verwendet)\n`; + text += ` ${Math.round(fav.nutrition.calories)} kcal, ${Math.round(fav.nutrition.protein)}g P, ${Math.round(fav.nutrition.carbohydrates)}g KH, ${Math.round(fav.nutrition.fat)}g F\n`; + } + + await this.sendMessage(roomId, text); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleTips(roomId: string, sender: string) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + try { + const recommendations = await this.nutriphiService.getRecommendations(token); + + if (recommendations.length === 0) { + await this.sendMessage( + roomId, + `Keine aktuellen Empfehlungen. Tracke mehr Mahlzeiten fur personalisierte Tipps!` + ); + return; + } + + let text = `**KI-Empfehlungen:**\n\n`; + for (const rec of recommendations) { + const icon = rec.type === 'coaching' ? '' : ''; + text += `${icon} ${rec.message}\n\n`; + } + + await this.sendMessage(roomId, text); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleStatus(roomId: string, sender: string) { + const backendHealthy = await this.nutriphiService.checkHealth(); + const isLoggedIn = this.sessionService.isLoggedIn(sender); + const sessionCount = this.sessionService.getSessionCount(); + const loggedInCount = this.sessionService.getLoggedInCount(); + + const statusText = `**NutriPhi Bot Status** + +**Backend:** ${backendHealthy ? 'Online' : 'Offline'} +**Dein Status:** ${isLoggedIn ? 'Angemeldet' : 'Nicht angemeldet'} +**Aktive Sessions:** ${sessionCount} (${loggedInCount} angemeldet) + +${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; + + await this.sendMessage(roomId, statusText); + } + + private async pinHelpMessage(roomId: string) { + try { + const htmlBody = this.markdownToHtml(HELP_MESSAGE); + + const eventId = await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: HELP_MESSAGE, + format: 'org.matrix.custom.html', + formatted_body: htmlBody, + }); + + await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { + pinned: [eventId], + }); + + this.logger.log(`Pinned help message in room ${roomId}`); + } catch (error) { + this.logger.error(`Failed to pin help message:`, error); + await this.sendMessage(roomId, 'Fehler beim Pinnen der Hilfe.'); + } + } + + private async downloadMatrixImage(mxcUrl: string): Promise { + const httpUrl = this.client.mxcToHttp(mxcUrl); + this.logger.log(`Downloading image from ${httpUrl}`); + + const response = await fetch(httpUrl); + if (!response.ok) { + throw new Error(`Failed to download image: ${response.status}`); + } + + const buffer = await response.arrayBuffer(); + const base64 = Buffer.from(buffer).toString('base64'); + return base64; + } + + private async sendMessage(roomId: string, message: string) { + const htmlBody = this.markdownToHtml(message); + + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: message, + format: 'org.matrix.custom.html', + formatted_body: htmlBody, + }); + } + + private markdownToHtml(markdown: string): string { + return ( + markdown + // Code blocks + .replace(/```(\w+)?\n([\s\S]*?)```/g, '
$2
') + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Bold + .replace(/\*\*([^*]+)\*\*/g, '$1') + // Italic + .replace(/\*([^*]+)\*/g, '$1') + // Underscore italic + .replace(/_([^_]+)_/g, '$1') + // Line breaks + .replace(/\n/g, '
') + ); + } +} diff --git a/services/matrix-nutriphi-bot/src/config/configuration.ts b/services/matrix-nutriphi-bot/src/config/configuration.ts new file mode 100644 index 000000000..b2bac2a1c --- /dev/null +++ b/services/matrix-nutriphi-bot/src/config/configuration.ts @@ -0,0 +1,48 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3316', 10), + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN || '', + allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',').filter(Boolean) || [], + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + nutriphi: { + backendUrl: process.env.NUTRIPHI_BACKEND_URL || 'http://localhost:3023', + apiPrefix: process.env.NUTRIPHI_API_PREFIX || '/api/v1', + }, + auth: { + url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', + devBypass: process.env.DEV_BYPASS_AUTH === 'true', + devUserId: process.env.DEV_USER_ID || '', + }, +}); + +export const HELP_MESSAGE = `**NutriPhi Bot - KI-Ernahrungsassistent** + +**Befehle:** +- \`!help\` - Diese Hilfe anzeigen +- \`!login email passwort\` - Bei NutriPhi anmelden +- \`!analyze [beschreibung]\` - Foto/Text analysieren +- \`!today\` / \`heute\` - Tages-Zusammenfassung +- \`!week\` / \`woche\` - Wochen-Statistik +- \`!goals\` / \`ziele\` - Aktuelle Ziele +- \`!setgoals kalorien protein carbs fett\` - Ziele setzen +- \`!favorites\` / \`favoriten\` - Favoriten anzeigen +- \`!tips\` / \`tipps\` - KI-Empfehlungen +- \`!status\` - Bot-Status + +**Bild-Analyse:** +1. Sende ein Foto deiner Mahlzeit +2. Dann: \`!analyze\` oder \`!analyze Spaghetti mit Sauce\` + +**Beispiele:** +- "heute" - Zeigt Tages-Ubersicht +- \`!analyze Apfel und Banane\` - Analysiert Textbeschreibung +- \`!setgoals 2000 80 250 65\` - Setzt Tagesziele`; + +export const MEAL_TYPE_LABELS: Record = { + breakfast: 'Fruhstuck', + lunch: 'Mittagessen', + dinner: 'Abendessen', + snack: 'Snack', +}; diff --git a/services/matrix-nutriphi-bot/src/health.controller.ts b/services/matrix-nutriphi-bot/src/health.controller.ts new file mode 100644 index 000000000..d1437171d --- /dev/null +++ b/services/matrix-nutriphi-bot/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'matrix-nutriphi-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/matrix-nutriphi-bot/src/main.ts b/services/matrix-nutriphi-bot/src/main.ts new file mode 100644 index 000000000..667aff302 --- /dev/null +++ b/services/matrix-nutriphi-bot/src/main.ts @@ -0,0 +1,15 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + const port = process.env.PORT || 3316; + await app.listen(port); + + logger.log(`Matrix NutriPhi Bot running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} +bootstrap(); diff --git a/services/matrix-nutriphi-bot/src/nutriphi/nutriphi.module.ts b/services/matrix-nutriphi-bot/src/nutriphi/nutriphi.module.ts new file mode 100644 index 000000000..77fa15ec8 --- /dev/null +++ b/services/matrix-nutriphi-bot/src/nutriphi/nutriphi.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { NutriPhiService } from './nutriphi.service'; + +@Module({ + providers: [NutriPhiService], + exports: [NutriPhiService], +}) +export class NutriPhiModule {} diff --git a/services/matrix-nutriphi-bot/src/nutriphi/nutriphi.service.ts b/services/matrix-nutriphi-bot/src/nutriphi/nutriphi.service.ts new file mode 100644 index 000000000..7a678cc9a --- /dev/null +++ b/services/matrix-nutriphi-bot/src/nutriphi/nutriphi.service.ts @@ -0,0 +1,235 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +// Types from NutriPhi backend +export interface DetectedFood { + name: string; + quantity: string; + calories: number; + confidence: number; +} + +export interface NutritionData { + calories: number; + protein: number; + carbohydrates: number; + fat: number; + fiber: number; + sugar: number; +} + +export interface AIAnalysisResult { + foods: DetectedFood[]; + totalNutrition: NutritionData; + description: string; + confidence: number; + warnings?: string[]; + suggestions?: string[]; +} + +export interface UserGoals { + id: string; + dailyCalories: number; + dailyProtein?: number | null; + dailyCarbs?: number | null; + dailyFat?: number | null; +} + +export interface Meal { + id: string; + date: Date; + mealType: string; + description: string; + confidence: number; +} + +export interface MealWithNutrition extends Meal { + nutrition?: NutritionData; +} + +export interface DailySummary { + date: Date; + meals: MealWithNutrition[]; + totalNutrition: NutritionData; + goals?: UserGoals; + progress: { + calories: { current: number; target: number; percentage: number }; + protein?: { current: number; target: number; percentage: number }; + carbs?: { current: number; target: number; percentage: number }; + fat?: { current: number; target: number; percentage: number }; + }; +} + +export interface WeeklyStats { + startDate: Date; + endDate: Date; + days: { + date: Date; + totalCalories: number; + totalProtein: number; + totalCarbs: number; + totalFat: number; + mealCount: number; + goalsMet: boolean; + }[]; + averages: { + calories: number; + protein: number; + carbs: number; + fat: number; + }; +} + +export interface FavoriteMeal { + id: string; + name: string; + nutrition: NutritionData; + usageCount: number; +} + +export interface Recommendation { + id: string; + type: 'hint' | 'coaching'; + message: string; +} + +@Injectable() +export class NutriPhiService { + private readonly logger = new Logger(NutriPhiService.name); + private readonly backendUrl: string; + private readonly apiPrefix: string; + + constructor(private configService: ConfigService) { + this.backendUrl = + this.configService.get('nutriphi.backendUrl') || 'http://localhost:3023'; + this.apiPrefix = this.configService.get('nutriphi.apiPrefix') || '/api/v1'; + } + + private getUrl(path: string): string { + return `${this.backendUrl}${this.apiPrefix}${path}`; + } + + private async request( + path: string, + options: RequestInit & { token?: string } = {} + ): Promise { + const { token, ...fetchOptions } = options; + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(this.getUrl(path), { + ...fetchOptions, + headers, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`NutriPhi API error (${response.status}): ${error}`); + } + + return response.json(); + } + + async checkHealth(): Promise { + try { + const response = await fetch(this.getUrl('/health')); + return response.ok; + } catch { + return false; + } + } + + async analyzePhoto( + imageBase64: string, + mimeType: string, + token: string + ): Promise { + return this.request('/analysis/photo', { + method: 'POST', + body: JSON.stringify({ image: imageBase64, mimeType }), + token, + }); + } + + async analyzeText(description: string, token: string): Promise { + return this.request('/analysis/text', { + method: 'POST', + body: JSON.stringify({ description }), + token, + }); + } + + async createMeal( + data: { + description: string; + mealType: string; + inputType: 'photo' | 'text'; + nutrition: NutritionData; + confidence: number; + }, + token: string + ): Promise { + return this.request('/meals', { + method: 'POST', + body: JSON.stringify(data), + token, + }); + } + + async getDailySummary(date: string, token: string): Promise { + return this.request(`/stats/daily?date=${date}`, { token }); + } + + async getWeeklyStats(date: string, token: string): Promise { + return this.request(`/stats/weekly?date=${date}`, { token }); + } + + async getGoals(token: string): Promise { + try { + return await this.request('/goals', { token }); + } catch { + return null; + } + } + + async setGoals( + goals: { + dailyCalories: number; + dailyProtein?: number; + dailyCarbs?: number; + dailyFat?: number; + }, + token: string + ): Promise { + return this.request('/goals', { + method: 'POST', + body: JSON.stringify(goals), + token, + }); + } + + async getFavorites(token: string): Promise { + return this.request('/favorites', { token }); + } + + async createFavorite( + data: { name: string; nutrition: NutritionData }, + token: string + ): Promise { + return this.request('/favorites', { + method: 'POST', + body: JSON.stringify(data), + token, + }); + } + + async getRecommendations(token: string): Promise { + return this.request('/recommendations', { token }); + } +} diff --git a/services/matrix-nutriphi-bot/src/session/session.module.ts b/services/matrix-nutriphi-bot/src/session/session.module.ts new file mode 100644 index 000000000..834b715eb --- /dev/null +++ b/services/matrix-nutriphi-bot/src/session/session.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SessionService } from './session.service'; + +@Module({ + providers: [SessionService], + exports: [SessionService], +}) +export class SessionModule {} diff --git a/services/matrix-nutriphi-bot/src/session/session.service.ts b/services/matrix-nutriphi-bot/src/session/session.service.ts new file mode 100644 index 000000000..1b84fab9a --- /dev/null +++ b/services/matrix-nutriphi-bot/src/session/session.service.ts @@ -0,0 +1,152 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface UserSession { + matrixUserId: string; + jwtToken?: string; + tokenExpiry?: Date; + pendingImage?: { url: string; mimeType: string }; + lastActivity: Date; +} + +export interface LoginResult { + success: boolean; + token?: string; + error?: string; +} + +@Injectable() +export class SessionService { + private readonly logger = new Logger(SessionService.name); + private sessions: Map = new Map(); + private readonly authUrl: string; + private readonly devBypass: boolean; + private readonly devUserId: string; + + constructor(private configService: ConfigService) { + this.authUrl = this.configService.get('auth.url') || 'http://localhost:3001'; + this.devBypass = this.configService.get('auth.devBypass') || false; + this.devUserId = this.configService.get('auth.devUserId') || ''; + } + + getSession(matrixUserId: string): UserSession { + if (!this.sessions.has(matrixUserId)) { + this.sessions.set(matrixUserId, { + matrixUserId, + lastActivity: new Date(), + }); + } + const session = this.sessions.get(matrixUserId)!; + session.lastActivity = new Date(); + return session; + } + + isLoggedIn(matrixUserId: string): boolean { + if (this.devBypass && this.devUserId) { + return true; + } + + const session = this.sessions.get(matrixUserId); + if (!session?.jwtToken || !session.tokenExpiry) { + return false; + } + + // Check if token is expired (with 5 minute buffer) + const now = new Date(); + const expiryBuffer = new Date(session.tokenExpiry.getTime() - 5 * 60 * 1000); + return now < expiryBuffer; + } + + getToken(matrixUserId: string): string | null { + if (this.devBypass && this.devUserId) { + // In dev mode, return a mock token (the backend should also bypass auth) + return 'dev-bypass-token'; + } + + const session = this.sessions.get(matrixUserId); + if (!session?.jwtToken || !this.isLoggedIn(matrixUserId)) { + return null; + } + return session.jwtToken; + } + + async login(matrixUserId: string, email: string, password: string): Promise { + try { + const response = await fetch(`${this.authUrl}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + const error = await response.text(); + this.logger.warn(`Login failed for ${matrixUserId}: ${response.status}`); + return { success: false, error: `Login fehlgeschlagen: ${error}` }; + } + + const data = await response.json(); + const { accessToken, expiresIn } = data; + + if (!accessToken) { + return { success: false, error: 'Kein Token erhalten' }; + } + + // Calculate expiry time (expiresIn is in seconds) + const expiryTime = expiresIn + ? new Date(Date.now() + expiresIn * 1000) + : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // Default: 7 days + + const session = this.getSession(matrixUserId); + session.jwtToken = accessToken; + session.tokenExpiry = expiryTime; + + this.logger.log(`User ${matrixUserId} logged in successfully`); + return { success: true, token: accessToken }; + } catch (error) { + this.logger.error(`Login error for ${matrixUserId}:`, error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unbekannter Fehler', + }; + } + } + + logout(matrixUserId: string): void { + const session = this.sessions.get(matrixUserId); + if (session) { + session.jwtToken = undefined; + session.tokenExpiry = undefined; + } + this.logger.log(`User ${matrixUserId} logged out`); + } + + setPendingImage(matrixUserId: string, url: string, mimeType: string): void { + const session = this.getSession(matrixUserId); + session.pendingImage = { url, mimeType }; + } + + getPendingImage(matrixUserId: string): { url: string; mimeType: string } | undefined { + return this.sessions.get(matrixUserId)?.pendingImage; + } + + clearPendingImage(matrixUserId: string): void { + const session = this.sessions.get(matrixUserId); + if (session) { + session.pendingImage = undefined; + } + } + + getSessionCount(): number { + return this.sessions.size; + } + + getLoggedInCount(): number { + let count = 0; + for (const [userId] of this.sessions) { + if (this.isLoggedIn(userId)) { + count++; + } + } + return count; + } +} diff --git a/services/matrix-nutriphi-bot/tsconfig.build.json b/services/matrix-nutriphi-bot/tsconfig.build.json new file mode 100644 index 000000000..4491981e0 --- /dev/null +++ b/services/matrix-nutriphi-bot/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/services/matrix-nutriphi-bot/tsconfig.json b/services/matrix-nutriphi-bot/tsconfig.json new file mode 100644 index 000000000..b439390d0 --- /dev/null +++ b/services/matrix-nutriphi-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + } +}