From 3f64c7422f925635b6f96a06f929db1ca1699bb4 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:43:41 +0100 Subject: [PATCH] feat(telegram-ollama-bot): add Telegram bot for local LLM inference via Ollama - NestJS-based Telegram bot with nestjs-telegraf - Ollama service for API communication with Gemma 3 4B - Commands: /start, /help, /models, /model, /mode, /clear, /status - Multiple modes: default, classify, summarize, translate, code - Chat history with context (last 10 messages) - User access control via TELEGRAM_ALLOWED_USERS - Health endpoint for monitoring - Updated MAC_MINI_SERVER.md with Ollama documentation Co-Authored-By: Claude Opus 4.5 --- docs/MAC_MINI_SERVER.md | 129 +++++++- pnpm-lock.yaml | 289 +++++++++++------- services/telegram-ollama-bot/CLAUDE.md | 130 ++++++++ services/telegram-ollama-bot/Dockerfile | 44 +++ services/telegram-ollama-bot/nest-cli.json | 8 + services/telegram-ollama-bot/package.json | 35 +++ .../telegram-ollama-bot/src/app.module.ts | 27 ++ .../telegram-ollama-bot/src/bot/bot.module.ts | 9 + .../telegram-ollama-bot/src/bot/bot.update.ts | 278 +++++++++++++++++ .../src/config/configuration.ts | 25 ++ .../src/health.controller.ts | 21 ++ services/telegram-ollama-bot/src/main.ts | 19 ++ .../src/ollama/ollama.module.ts | 8 + .../src/ollama/ollama.service.ts | 138 +++++++++ services/telegram-ollama-bot/tsconfig.json | 22 ++ 15 files changed, 1061 insertions(+), 121 deletions(-) create mode 100644 services/telegram-ollama-bot/CLAUDE.md create mode 100644 services/telegram-ollama-bot/Dockerfile create mode 100644 services/telegram-ollama-bot/nest-cli.json create mode 100644 services/telegram-ollama-bot/package.json create mode 100644 services/telegram-ollama-bot/src/app.module.ts create mode 100644 services/telegram-ollama-bot/src/bot/bot.module.ts create mode 100644 services/telegram-ollama-bot/src/bot/bot.update.ts create mode 100644 services/telegram-ollama-bot/src/config/configuration.ts create mode 100644 services/telegram-ollama-bot/src/health.controller.ts create mode 100644 services/telegram-ollama-bot/src/main.ts create mode 100644 services/telegram-ollama-bot/src/ollama/ollama.module.ts create mode 100644 services/telegram-ollama-bot/src/ollama/ollama.service.ts create mode 100644 services/telegram-ollama-bot/tsconfig.json diff --git a/docs/MAC_MINI_SERVER.md b/docs/MAC_MINI_SERVER.md index d966d3743..b146b7349 100644 --- a/docs/MAC_MINI_SERVER.md +++ b/docs/MAC_MINI_SERVER.md @@ -16,12 +16,12 @@ Cloudflare Tunnel (cloudflared) │ ▼ ┌─────────────────────────────────────────────────────────────┐ -│ Mac Mini (mana-server) │ +│ Mac Mini M4 (mana-server) │ │ │ -│ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ PostgreSQL │ │ Redis │ │ -│ │ (Docker) │ │ (Docker) │ │ -│ └─────────────────┘ └─────────────────┘ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────┐ │ +│ │ PostgreSQL │ │ Redis │ │ Ollama │ │ +│ │ (Docker) │ │ (Docker) │ │ (nativ) │ │ +│ └─────────────────┘ └─────────────────┘ └────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Docker Container │ │ @@ -36,10 +36,18 @@ Cloudflare Tunnel (cloudflared) │ │ ├── clock-backend (Port 3017) │ │ │ │ └── clock-web (Port 5187) │ │ │ └─────────────────────────────────────────────────────┘ │ +│ ▲ │ +│ │ host.docker.internal:11434 │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Ollama (Port 11434) - Gemma 3 4B │ │ +│ │ ~53 t/s Generation | Metal GPU Acceleration │ │ +│ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ LaunchAgents (Autostart) │ │ │ │ ├── cloudflared (Tunnel) │ │ +│ │ ├── ollama (LLM Service) │ │ │ │ ├── docker-startup (Container beim Boot) │ │ │ │ └── health-check (alle 5 Minuten) │ │ │ └─────────────────────────────────────────────────────┘ │ @@ -383,6 +391,116 @@ docker image prune -a | `stop.sh` | Stoppt alle Container | | `deploy.sh` | Pullt neue Images und startet neu | +## Ollama (Lokale KI) + +Ollama läuft nativ auf dem Mac Mini für lokale LLM-Inferenz (Klassifizierung, Text-Analyse, etc.). + +### Hardware + +- **Chip:** Apple M4 (10 Cores) +- **RAM:** 16 GB Unified Memory + +### Installation + +```bash +# Bereits installiert via Homebrew +/opt/homebrew/bin/brew install ollama +/opt/homebrew/bin/brew services start ollama +``` + +### Konfiguration + +**LaunchAgent:** `~/Library/LaunchAgents/homebrew.mxcl.ollama.plist` + +Optimierungen bereits aktiviert: +- `OLLAMA_FLASH_ATTENTION=1` - Schnellere Attention-Berechnung +- `OLLAMA_KV_CACHE_TYPE=q8_0` - Effizienterer KV-Cache + +### Verfügbare Modelle + +| Modell | Größe | Zweck | +|--------|-------|-------| +| gemma3:4b | 3.3 GB | Klassifizierung, kurze Antworten | + +```bash +# Modelle auflisten +/opt/homebrew/bin/ollama list + +# Neues Modell herunterladen +/opt/homebrew/bin/ollama pull gemma3:12b +``` + +### Performance (gemessen) + +| Metrik | Wert | +|--------|------| +| Text Generation | ~53 tokens/sec | +| Prompt Processing | ~260 tokens/sec | +| Latenz (kurze Anfrage) | ~0.4 sec | + +### API-Zugriff + +**Lokaler Endpunkt:** `http://localhost:11434` + +```bash +# Generate API +curl http://localhost:11434/api/generate -d '{ + "model": "gemma3:4b", + "prompt": "Klassifiziere: Newsletter oder Spam?", + "stream": false +}' + +# OpenAI-kompatible API +curl http://localhost:11434/v1/chat/completions -d '{ + "model": "gemma3:4b", + "messages": [{"role": "user", "content": "Hallo"}] +}' +``` + +### Zugriff aus Docker-Containern + +Docker-Container können Ollama über `host.docker.internal` erreichen: + +```bash +# Aus einem Container heraus +curl http://host.docker.internal:11434/api/generate -d '...' +``` + +Oder in Docker Compose Environment-Variablen: +```yaml +environment: + OLLAMA_URL: http://host.docker.internal:11434 +``` + +### Ollama Management + +```bash +# Service Status +/opt/homebrew/bin/brew services info ollama + +# Service neustarten +/opt/homebrew/bin/brew services restart ollama + +# Logs prüfen +tail -f /opt/homebrew/var/log/ollama.log + +# Modell entfernen +/opt/homebrew/bin/ollama rm gemma3:4b +``` + +### Troubleshooting + +```bash +# Prüfen ob Ollama läuft +curl http://localhost:11434/api/version + +# GPU-Nutzung prüfen (sollte Metal verwenden) +/opt/homebrew/bin/ollama ps + +# Bei Problemen: Service neustarten +/opt/homebrew/bin/brew services restart ollama +``` + ## Chronologie der Einrichtung 1. **Docker Setup** - PostgreSQL, Redis, App-Container @@ -392,3 +510,4 @@ docker image prune -a 5. **Health Checks** - Automatische Überwachung 6. **Telegram Notifications** - Alerts bei Fehlern 7. **Email Notifications** - Redundante Benachrichtigung +8. **Ollama** - Lokale LLM-Inferenz (Gemma 3 4B) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4093291f..533487e02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4438,7 +4438,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) @@ -4613,6 +4613,49 @@ importers: specifier: ^5.7.2 version: 5.9.3 + services/telegram-ollama-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)(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) + nestjs-telegraf: + specifier: ^2.8.0 + version: 2.9.1(@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)(reflect-metadata@0.2.2)(telegraf@4.16.3)(typescript@5.9.3) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + telegraf: + specifier: ^4.16.3 + version: 4.16.3 + 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 + rimraf: + specifier: ^6.0.1 + version: 6.1.2 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + services/telegram-stats-bot: dependencies: '@nestjs/common': @@ -23314,7 +23357,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 6.0.15(ohit2up6tuxb3x34brxduivol4) + expo-router: 6.0.15(dux2nvtiztnejw7mxzfaajqvh4) react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -24583,7 +24626,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 @@ -24597,7 +24640,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 @@ -24618,7 +24661,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 @@ -24632,7 +24675,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 @@ -25051,6 +25094,32 @@ snapshots: axios: 1.13.2 rxjs: 7.8.2 + '@nestjs/cli@10.4.9': + 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) + 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 + 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) @@ -28446,19 +28515,6 @@ snapshots: pretty-format: 27.5.1 optional: true - '@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 @@ -31593,13 +31649,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: @@ -34411,53 +34467,6 @@ snapshots: - supports-color optional: true - expo-router@6.0.15(ohit2up6tuxb3x34brxduivol4): - 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(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.100.2(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(qjp3usx4acoq47dkosl6pmu254): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.13)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -35403,6 +35412,23 @@ snapshots: typescript: 5.7.2 webpack: 5.97.1(esbuild@0.27.0) + fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1): + 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 + 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 @@ -36600,16 +36626,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 @@ -36697,36 +36723,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 @@ -36758,7 +36754,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 @@ -36784,6 +36811,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 @@ -37370,12 +37398,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 @@ -41338,16 +41366,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.100.2(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.100.2(esbuild@0.19.12) - 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.100.2(esbuild@0.27.0)): dependencies: acorn-loose: 8.5.2 @@ -42668,6 +42686,15 @@ snapshots: optionalDependencies: esbuild: 0.27.0 + terser-webpack-plugin@5.3.14(webpack@5.97.1): + 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 + terser@5.44.1: dependencies: '@jridgewell/source-map': 0.3.11 @@ -43901,6 +43928,36 @@ snapshots: - esbuild - uglify-js + webpack@5.97.1: + 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(webpack@5.97.1) + 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/telegram-ollama-bot/CLAUDE.md b/services/telegram-ollama-bot/CLAUDE.md new file mode 100644 index 000000000..df13eed23 --- /dev/null +++ b/services/telegram-ollama-bot/CLAUDE.md @@ -0,0 +1,130 @@ +# Telegram Ollama Bot + +Telegram Bot für lokale LLM-Inferenz via Ollama auf dem Mac Mini Server. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Telegram**: nestjs-telegraf + Telegraf +- **LLM**: Ollama API (Gemma 3 4B) + +## Commands + +```bash +# Development +pnpm start:dev # Start with hot reload + +# Build +pnpm build # Production build + +# Type check +pnpm type-check # Check TypeScript types +``` + +## Telegram Commands + +| Command | Beschreibung | +|---------|--------------| +| `/start` | Hilfe anzeigen | +| `/help` | Hilfe anzeigen | +| `/models` | Verfügbare Modelle auflisten | +| `/model [name]` | Modell wechseln | +| `/mode [modus]` | System-Prompt ändern | +| `/clear` | Chat-Verlauf löschen | +| `/status` | Ollama-Status prüfen | + +## Modi + +| Modus | Beschreibung | +|-------|--------------| +| `default` | Allgemeiner Assistent | +| `classify` | Text-Klassifizierung | +| `summarize` | Zusammenfassungen | +| `translate` | Übersetzungen | +| `code` | Programmier-Hilfe | + +## Environment Variables + +```env +# Server +PORT=3301 + +# Telegram +TELEGRAM_BOT_TOKEN=xxx # Bot Token von @BotFather +TELEGRAM_ALLOWED_USERS=123,456 # Optional: Nur diese User IDs erlauben + +# Ollama +OLLAMA_URL=http://localhost:11434 # Ollama API URL +OLLAMA_MODEL=gemma3:4b # Standard-Modell +OLLAMA_TIMEOUT=120000 # Timeout in ms +``` + +## Projekt-Struktur + +``` +services/telegram-ollama-bot/ +├── src/ +│ ├── main.ts # Entry point +│ ├── app.module.ts # Root module +│ ├── health.controller.ts # Health endpoint +│ ├── config/ +│ │ └── configuration.ts # Config & System Prompts +│ ├── bot/ +│ │ ├── bot.module.ts +│ │ └── bot.update.ts # Command handlers +│ └── ollama/ +│ ├── ollama.module.ts +│ └── ollama.service.ts # Ollama API client +└── Dockerfile +``` + +## Deployment auf Mac Mini + +### Option 1: Docker + +```yaml +# In docker-compose.macmini.yml +telegram-ollama-bot: + image: ghcr.io/memo-2023/telegram-ollama-bot:latest + container_name: manacore-telegram-ollama-bot + restart: always + environment: + PORT: 3301 + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} + OLLAMA_URL: http://host.docker.internal:11434 + OLLAMA_MODEL: gemma3:4b + ports: + - "3301:3301" +``` + +### Option 2: Nativ (empfohlen für beste Ollama-Performance) + +```bash +# Auf dem Mac Mini +cd ~/projects/manacore-monorepo/services/telegram-ollama-bot +pnpm install +pnpm build +TELEGRAM_BOT_TOKEN=xxx OLLAMA_URL=http://localhost:11434 pnpm start:prod +``` + +## Neuen Bot erstellen + +1. Öffne @BotFather in Telegram +2. Sende `/newbot` +3. Wähle einen Namen (z.B. "ManaCore Ollama") +4. Wähle einen Username (z.B. "manacore_ollama_bot") +5. Kopiere den Token + +## Health Check + +```bash +curl http://localhost:3301/health +``` + +## Features + +- **Chat-Verlauf**: Behält die letzten 10 Nachrichten für Kontext +- **Mehrere Modi**: Verschiedene System-Prompts für unterschiedliche Aufgaben +- **Modell-Wechsel**: Dynamisch zwischen installierten Modellen wechseln +- **User-Beschränkung**: Optional nur bestimmte Telegram-User erlauben +- **Lange Antworten**: Automatisches Splitting bei >4000 Zeichen diff --git a/services/telegram-ollama-bot/Dockerfile b/services/telegram-ollama-bot/Dockerfile new file mode 100644 index 000000000..6ecb3e028 --- /dev/null +++ b/services/telegram-ollama-bot/Dockerfile @@ -0,0 +1,44 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install pnpm +RUN npm install -g pnpm + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install dependencies +RUN pnpm install --frozen-lockfile || pnpm install + +# Copy source +COPY . . + +# Build +RUN pnpm build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# Install pnpm +RUN npm install -g pnpm + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install production dependencies only +RUN pnpm install --prod --frozen-lockfile || pnpm install --prod + +# Copy built application +COPY --from=builder /app/dist ./dist + +# Set environment +ENV NODE_ENV=production +ENV PORT=3301 + +EXPOSE 3301 + +CMD ["node", "dist/main.js"] diff --git a/services/telegram-ollama-bot/nest-cli.json b/services/telegram-ollama-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/telegram-ollama-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/telegram-ollama-bot/package.json b/services/telegram-ollama-bot/package.json new file mode 100644 index 000000000..17cc0e3d1 --- /dev/null +++ b/services/telegram-ollama-bot/package.json @@ -0,0 +1,35 @@ +{ + "name": "@manacore/telegram-ollama-bot", + "version": "1.0.0", + "description": "Telegram bot for local LLM inference via Ollama", + "private": true, + "license": "MIT", + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "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", + "nestjs-telegraf": "^2.8.0", + "telegraf": "^4.16.3", + "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", + "rimraf": "^6.0.1", + "typescript": "^5.7.3" + } +} diff --git a/services/telegram-ollama-bot/src/app.module.ts b/services/telegram-ollama-bot/src/app.module.ts new file mode 100644 index 000000000..1f3fb0920 --- /dev/null +++ b/services/telegram-ollama-bot/src/app.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TelegrafModule } from 'nestjs-telegraf'; +import configuration from './config/configuration'; +import { BotModule } from './bot/bot.module'; +import { OllamaModule } from './ollama/ollama.module'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + TelegrafModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + token: configService.get('telegram.token') || '', + }), + inject: [ConfigService], + }), + BotModule, + OllamaModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/telegram-ollama-bot/src/bot/bot.module.ts b/services/telegram-ollama-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..38eec038a --- /dev/null +++ b/services/telegram-ollama-bot/src/bot/bot.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { BotUpdate } from './bot.update'; +import { OllamaModule } from '../ollama/ollama.module'; + +@Module({ + imports: [OllamaModule], + providers: [BotUpdate], +}) +export class BotModule {} diff --git a/services/telegram-ollama-bot/src/bot/bot.update.ts b/services/telegram-ollama-bot/src/bot/bot.update.ts new file mode 100644 index 000000000..e210845db --- /dev/null +++ b/services/telegram-ollama-bot/src/bot/bot.update.ts @@ -0,0 +1,278 @@ +import { Logger } from '@nestjs/common'; +import { Update, Ctx, Start, Help, Command, On, Message } from 'nestjs-telegraf'; +import { Context } from 'telegraf'; +import { ConfigService } from '@nestjs/config'; +import { OllamaService } from '../ollama/ollama.service'; +import { SYSTEM_PROMPTS } from '../config/configuration'; + +interface UserSession { + systemPrompt: string; + model: string; + history: { role: 'user' | 'assistant'; content: string }[]; +} + +@Update() +export class BotUpdate { + private readonly logger = new Logger(BotUpdate.name); + private readonly allowedUsers: number[]; + private sessions: Map = new Map(); + + constructor( + private readonly ollamaService: OllamaService, + private configService: ConfigService + ) { + this.allowedUsers = this.configService.get('telegram.allowedUsers') || []; + } + + private isAllowed(userId: number): boolean { + // If no users configured, allow all + if (this.allowedUsers.length === 0) return true; + return this.allowedUsers.includes(userId); + } + + private getSession(userId: number): UserSession { + if (!this.sessions.has(userId)) { + this.sessions.set(userId, { + systemPrompt: SYSTEM_PROMPTS.default, + model: this.ollamaService.getDefaultModel(), + history: [], + }); + } + return this.sessions.get(userId)!; + } + + private formatHelp(): string { + return `Ollama Bot - Lokale KI + +Commands: +/start - Diese Hilfe anzeigen +/help - Diese Hilfe anzeigen +/models - Verfügbare Modelle anzeigen +/model [name] - Modell wechseln +/mode [modus] - System-Prompt ändern +/clear - Chat-Verlauf löschen +/status - Ollama Status prüfen + +Modi: +• default - Allgemeiner Assistent +• classify - Text-Klassifizierung +• summarize - Zusammenfassungen +• translate - Übersetzungen +• code - Programmier-Hilfe + +Verwendung: +Schreibe einfach eine Nachricht und ich antworte! + +Aktuelles Modell: ${this.ollamaService.getDefaultModel()}`; + } + + @Start() + async start(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + if (!userId || !this.isAllowed(userId)) { + await ctx.reply('Zugriff verweigert.'); + return; + } + + this.logger.log(`/start from user ${userId}`); + await ctx.replyWithHTML(this.formatHelp()); + } + + @Help() + async help(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + if (!userId || !this.isAllowed(userId)) { + await ctx.reply('Zugriff verweigert.'); + return; + } + + await ctx.replyWithHTML(this.formatHelp()); + } + + @Command('models') + async models(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + if (!userId || !this.isAllowed(userId)) { + await ctx.reply('Zugriff verweigert.'); + return; + } + + this.logger.log(`/models from user ${userId}`); + + const models = await this.ollamaService.listModels(); + if (models.length === 0) { + await ctx.reply('Keine Modelle gefunden. Ist Ollama gestartet?'); + return; + } + + const session = this.getSession(userId); + const modelList = models + .map((m) => { + const sizeMB = (m.size / 1024 / 1024).toFixed(0); + const active = m.name === session.model ? ' ✓' : ''; + return `• ${m.name} (${sizeMB} MB)${active}`; + }) + .join('\n'); + + await ctx.replyWithHTML( + `Verfügbare Modelle:\n\n${modelList}\n\nWechseln mit: /model [name]` + ); + } + + @Command('model') + async setModel(@Ctx() ctx: Context, @Message('text') text: string) { + const userId = ctx.from?.id; + if (!userId || !this.isAllowed(userId)) { + await ctx.reply('Zugriff verweigert.'); + return; + } + + const modelName = text.replace('/model', '').trim(); + if (!modelName) { + const session = this.getSession(userId); + await ctx.reply(`Aktuelles Modell: ${session.model}\n\nVerwendung: /model gemma3:4b`); + return; + } + + const models = await this.ollamaService.listModels(); + const exists = models.some((m) => m.name === modelName); + + if (!exists) { + await ctx.reply( + `Modell "${modelName}" nicht gefunden. Verfügbar: ${models.map((m) => m.name).join(', ')}` + ); + return; + } + + const session = this.getSession(userId); + session.model = modelName; + session.history = []; // Clear history on model change + + this.logger.log(`User ${userId} switched to model ${modelName}`); + await ctx.reply(`Modell gewechselt zu: ${modelName}`); + } + + @Command('mode') + async setMode(@Ctx() ctx: Context, @Message('text') text: string) { + const userId = ctx.from?.id; + if (!userId || !this.isAllowed(userId)) { + await ctx.reply('Zugriff verweigert.'); + return; + } + + const mode = text.replace('/mode', '').trim().toLowerCase(); + const availableModes = Object.keys(SYSTEM_PROMPTS); + + if (!mode) { + const session = this.getSession(userId); + const currentMode = + Object.entries(SYSTEM_PROMPTS).find(([_, v]) => v === session.systemPrompt)?.[0] || + 'custom'; + await ctx.reply(`Aktueller Modus: ${currentMode}\n\nVerfügbar: ${availableModes.join(', ')}`); + return; + } + + if (!SYSTEM_PROMPTS[mode]) { + await ctx.reply(`Unbekannter Modus: ${mode}\n\nVerfügbar: ${availableModes.join(', ')}`); + return; + } + + const session = this.getSession(userId); + session.systemPrompt = SYSTEM_PROMPTS[mode]; + session.history = []; // Clear history on mode change + + this.logger.log(`User ${userId} switched to mode ${mode}`); + await ctx.reply(`Modus gewechselt zu: ${mode}`); + } + + @Command('clear') + async clear(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + if (!userId || !this.isAllowed(userId)) { + await ctx.reply('Zugriff verweigert.'); + return; + } + + const session = this.getSession(userId); + session.history = []; + + this.logger.log(`User ${userId} cleared history`); + await ctx.reply('Chat-Verlauf gelöscht.'); + } + + @Command('status') + async status(@Ctx() ctx: Context) { + const userId = ctx.from?.id; + if (!userId || !this.isAllowed(userId)) { + await ctx.reply('Zugriff verweigert.'); + return; + } + + const connected = await this.ollamaService.checkConnection(); + const models = await this.ollamaService.listModels(); + const session = this.getSession(userId); + + const statusText = `Ollama Status + +Verbindung: ${connected ? '✅ Online' : '❌ Offline'} +Modelle: ${models.length} +Dein Modell: ${session.model} +Chat-Verlauf: ${session.history.length} Nachrichten`; + + await ctx.replyWithHTML(statusText); + } + + @On('text') + async onMessage(@Ctx() ctx: Context, @Message('text') text: string) { + const userId = ctx.from?.id; + if (!userId || !this.isAllowed(userId)) { + await ctx.reply('Zugriff verweigert.'); + return; + } + + // Ignore commands + if (text.startsWith('/')) return; + + this.logger.log(`Message from user ${userId}: ${text.substring(0, 50)}...`); + + const session = this.getSession(userId); + + // Show typing indicator + await ctx.sendChatAction('typing'); + + try { + // Add user message to history + session.history.push({ role: 'user', content: text }); + + // Keep only last 10 messages to avoid context overflow + if (session.history.length > 10) { + session.history = session.history.slice(-10); + } + + // Build messages with system prompt + const messages: { role: 'user' | 'assistant' | 'system'; content: string }[] = [ + { role: 'system', content: session.systemPrompt }, + ...session.history, + ]; + + const response = await this.ollamaService.chat(messages, session.model); + + // Add assistant response to history + session.history.push({ role: 'assistant', content: response }); + + // Split long messages (Telegram limit is 4096 chars) + if (response.length > 4000) { + const chunks = response.match(/.{1,4000}/gs) || []; + for (const chunk of chunks) { + await ctx.reply(chunk); + } + } else { + await ctx.reply(response); + } + } catch (error) { + this.logger.error(`Error processing message:`, error); + const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await ctx.reply(`Fehler: ${errorMessage}`); + } + } +} diff --git a/services/telegram-ollama-bot/src/config/configuration.ts b/services/telegram-ollama-bot/src/config/configuration.ts new file mode 100644 index 000000000..667b1ca75 --- /dev/null +++ b/services/telegram-ollama-bot/src/config/configuration.ts @@ -0,0 +1,25 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3301', 10), + telegram: { + token: process.env.TELEGRAM_BOT_TOKEN, + allowedUsers: + process.env.TELEGRAM_ALLOWED_USERS?.split(',').map((id) => parseInt(id, 10)) || [], + }, + ollama: { + url: process.env.OLLAMA_URL || 'http://localhost:11434', + model: process.env.OLLAMA_MODEL || 'gemma3:4b', + timeout: parseInt(process.env.OLLAMA_TIMEOUT || '120000', 10), + }, +}); + +export const SYSTEM_PROMPTS: Record = { + default: + 'Du bist ein hilfreicher Assistent. Antworte präzise und auf Deutsch, wenn der User Deutsch schreibt.', + classify: + 'Du bist ein Klassifikations-Experte. Analysiere den gegebenen Text und ordne ihn einer passenden Kategorie zu. Antworte kurz und präzise.', + summarize: + 'Du bist ein Zusammenfassungs-Experte. Fasse den gegebenen Text kurz und prägnant zusammen. Behalte die wichtigsten Informationen bei.', + translate: + 'Du bist ein Übersetzer. Übersetze den Text in die gewünschte Sprache. Behalte den Ton und Stil bei.', + code: 'Du bist ein Programmier-Assistent. Hilf bei Code-Fragen, erkläre Konzepte und schlage Verbesserungen vor.', +}; diff --git a/services/telegram-ollama-bot/src/health.controller.ts b/services/telegram-ollama-bot/src/health.controller.ts new file mode 100644 index 000000000..e6128cf81 --- /dev/null +++ b/services/telegram-ollama-bot/src/health.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get } from '@nestjs/common'; +import { OllamaService } from './ollama/ollama.service'; + +@Controller() +export class HealthController { + constructor(private readonly ollamaService: OllamaService) {} + + @Get('health') + async health() { + const ollamaConnected = await this.ollamaService.checkConnection(); + + return { + status: ollamaConnected ? 'ok' : 'degraded', + timestamp: new Date().toISOString(), + ollama: { + connected: ollamaConnected, + model: this.ollamaService.getDefaultModel(), + }, + }; + } +} diff --git a/services/telegram-ollama-bot/src/main.ts b/services/telegram-ollama-bot/src/main.ts new file mode 100644 index 000000000..0f444fde6 --- /dev/null +++ b/services/telegram-ollama-bot/src/main.ts @@ -0,0 +1,19 @@ +import { NestFactory } from '@nestjs/core'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + const configService = app.get(ConfigService); + const port = configService.get('port') || 3301; + + await app.listen(port); + logger.log(`Telegram Ollama Bot running on port ${port}`); + logger.log(`Ollama URL: ${configService.get('ollama.url')}`); + logger.log(`Default model: ${configService.get('ollama.model')}`); +} + +bootstrap(); diff --git a/services/telegram-ollama-bot/src/ollama/ollama.module.ts b/services/telegram-ollama-bot/src/ollama/ollama.module.ts new file mode 100644 index 000000000..a0ae211c4 --- /dev/null +++ b/services/telegram-ollama-bot/src/ollama/ollama.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { OllamaService } from './ollama.service'; + +@Module({ + providers: [OllamaService], + exports: [OllamaService], +}) +export class OllamaModule {} diff --git a/services/telegram-ollama-bot/src/ollama/ollama.service.ts b/services/telegram-ollama-bot/src/ollama/ollama.service.ts new file mode 100644 index 000000000..ebb38c7df --- /dev/null +++ b/services/telegram-ollama-bot/src/ollama/ollama.service.ts @@ -0,0 +1,138 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface OllamaGenerateResponse { + model: string; + response: string; + done: boolean; + total_duration?: number; + eval_count?: number; + eval_duration?: number; +} + +interface OllamaModel { + name: string; + size: number; + modified_at: string; +} + +@Injectable() +export class OllamaService implements OnModuleInit { + private readonly logger = new Logger(OllamaService.name); + private readonly baseUrl: string; + private readonly defaultModel: string; + private readonly timeout: number; + + constructor(private configService: ConfigService) { + this.baseUrl = this.configService.get('ollama.url') || 'http://localhost:11434'; + this.defaultModel = this.configService.get('ollama.model') || 'gemma3:4b'; + this.timeout = this.configService.get('ollama.timeout') || 120000; + } + + async onModuleInit() { + await this.checkConnection(); + } + + async checkConnection(): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/version`, { + signal: AbortSignal.timeout(5000), + }); + const data = await response.json(); + this.logger.log(`Ollama connected: v${data.version}`); + return true; + } catch (error) { + this.logger.error(`Failed to connect to Ollama at ${this.baseUrl}:`, error); + return false; + } + } + + async listModels(): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/tags`); + const data = await response.json(); + return data.models || []; + } catch (error) { + this.logger.error('Failed to list models:', error); + return []; + } + } + + async generate(prompt: string, systemPrompt?: string, model?: string): Promise { + const selectedModel = model || this.defaultModel; + + const body: Record = { + model: selectedModel, + prompt, + stream: false, + }; + + if (systemPrompt) { + body.system = systemPrompt; + } + + try { + const response = await fetch(`${this.baseUrl}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + throw new Error(`Ollama API error: ${response.status}`); + } + + const data: OllamaGenerateResponse = await response.json(); + + // Log performance metrics + if (data.eval_count && data.eval_duration) { + const tokensPerSec = (data.eval_count / data.eval_duration) * 1e9; + this.logger.debug(`Generated ${data.eval_count} tokens at ${tokensPerSec.toFixed(1)} t/s`); + } + + return data.response; + } catch (error) { + if (error instanceof Error && error.name === 'TimeoutError') { + throw new Error('Ollama Timeout - Antwort dauerte zu lange'); + } + throw error; + } + } + + async chat( + messages: { role: 'user' | 'assistant' | 'system'; content: string }[], + model?: string + ): Promise { + const selectedModel = model || this.defaultModel; + + try { + const response = await fetch(`${this.baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: selectedModel, + messages, + stream: false, + }), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + throw new Error(`Ollama API error: ${response.status}`); + } + + const data = await response.json(); + return data.message?.content || ''; + } catch (error) { + if (error instanceof Error && error.name === 'TimeoutError') { + throw new Error('Ollama Timeout - Antwort dauerte zu lange'); + } + throw error; + } + } + + getDefaultModel(): string { + return this.defaultModel; + } +} diff --git a/services/telegram-ollama-bot/tsconfig.json b/services/telegram-ollama-bot/tsconfig.json new file mode 100644 index 000000000..94f1e9493 --- /dev/null +++ b/services/telegram-ollama-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "esModuleInterop": true + } +}