From 3f336de1b9e2c9bc1c232576cea55d43e8884192 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:36:30 +0100 Subject: [PATCH] feat(matrix-planta-bot): add Matrix bot for plant care management - Full NestJS bot with matrix-bot-sdk integration - Plant CRUD: list, add, view, edit, delete plants - Watering: mark as watered, upcoming waterings, history - Care settings: light, humidity, temperature, soil, notes - Watering interval configuration - Health status tracking with emoji indicators - German/English command aliases - Number-based reference system for plants - JWT auth via mana-core-auth - Runs on port 3322 Co-Authored-By: Claude Opus 4.5 --- services/matrix-planta-bot/.env.example | 15 + services/matrix-planta-bot/.gitignore | 29 + services/matrix-planta-bot/CLAUDE.md | 197 ++++++ services/matrix-planta-bot/Dockerfile | 41 ++ services/matrix-planta-bot/nest-cli.json | 5 + services/matrix-planta-bot/package.json | 27 + services/matrix-planta-bot/src/app.module.ts | 21 + .../matrix-planta-bot/src/bot/bot.module.ts | 11 + .../src/bot/matrix.service.ts | 625 ++++++++++++++++++ .../src/config/configuration.ts | 57 ++ .../src/health.controller.ts | 9 + services/matrix-planta-bot/src/main.ts | 10 + .../src/planta/planta.module.ts | 8 + .../src/planta/planta.service.ts | 162 +++++ .../src/session/session.module.ts | 8 + .../src/session/session.service.ts | 90 +++ services/matrix-planta-bot/tsconfig.json | 22 + 17 files changed, 1337 insertions(+) create mode 100644 services/matrix-planta-bot/.env.example create mode 100644 services/matrix-planta-bot/.gitignore create mode 100644 services/matrix-planta-bot/CLAUDE.md create mode 100644 services/matrix-planta-bot/Dockerfile create mode 100644 services/matrix-planta-bot/nest-cli.json create mode 100644 services/matrix-planta-bot/package.json create mode 100644 services/matrix-planta-bot/src/app.module.ts create mode 100644 services/matrix-planta-bot/src/bot/bot.module.ts create mode 100644 services/matrix-planta-bot/src/bot/matrix.service.ts create mode 100644 services/matrix-planta-bot/src/config/configuration.ts create mode 100644 services/matrix-planta-bot/src/health.controller.ts create mode 100644 services/matrix-planta-bot/src/main.ts create mode 100644 services/matrix-planta-bot/src/planta/planta.module.ts create mode 100644 services/matrix-planta-bot/src/planta/planta.service.ts create mode 100644 services/matrix-planta-bot/src/session/session.module.ts create mode 100644 services/matrix-planta-bot/src/session/session.service.ts create mode 100644 services/matrix-planta-bot/tsconfig.json diff --git a/services/matrix-planta-bot/.env.example b/services/matrix-planta-bot/.env.example new file mode 100644 index 000000000..aefa021b0 --- /dev/null +++ b/services/matrix-planta-bot/.env.example @@ -0,0 +1,15 @@ +# Server +PORT=3322 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#planta:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Planta Backend +PLANTA_BACKEND_URL=http://localhost:3022 +PLANTA_API_PREFIX=/api + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/services/matrix-planta-bot/.gitignore b/services/matrix-planta-bot/.gitignore new file mode 100644 index 000000000..2d508e5ec --- /dev/null +++ b/services/matrix-planta-bot/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment +.env +.env.local + +# Data +data/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# TypeScript +*.tsbuildinfo diff --git a/services/matrix-planta-bot/CLAUDE.md b/services/matrix-planta-bot/CLAUDE.md new file mode 100644 index 000000000..a9bb75230 --- /dev/null +++ b/services/matrix-planta-bot/CLAUDE.md @@ -0,0 +1,197 @@ +# Matrix Planta Bot - Claude Code Guidelines + +## Overview + +Matrix Planta Bot provides plant care management via Matrix chat. It integrates with the Planta backend for plant CRUD operations, watering schedules, watering history, and care settings. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Backend**: Planta API (port 3022) +- **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-planta-bot/ +├── src/ +│ ├── main.ts # Application entry point (port 3322) +│ ├── 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 +│ ├── planta/ +│ │ ├── planta.module.ts +│ │ └── planta.service.ts # Planta Backend API client +│ └── session/ +│ ├── session.module.ts +│ └── session.service.ts # User session & auth management +├── Dockerfile +└── package.json +``` + +## Bot Commands + +| Command | Aliases | Description | +|---------|---------|-------------| +| `!help` | hilfe | Show help message | +| `!login email pass` | - | Login | +| `!logout` | - | Logout | +| `!status` | - | Bot status | + +### Plant Management + +| Command | Aliases | Description | +|---------|---------|-------------| +| `!pflanzen` | plants, liste | List all plants | +| `!pflanze [nr]` | plant, details | Show plant details | +| `!neu Name` | new, add | Add new plant | +| `!loeschen [nr]` | delete, entfernen | Remove plant | +| `!edit [nr] [feld] [wert]` | bearbeiten | Edit plant field | + +### Watering + +| Command | Aliases | Description | +|---------|---------|-------------| +| `!giessen [nr]` | water | Mark plant as watered | +| `!giessen [nr] Notiz` | - | Water with note | +| `!faellig` | due, upcoming | Show watering status | +| `!historie [nr]` | history, verlauf | Watering history | +| `!intervall [nr] [tage]` | interval, frequenz | Set watering interval | + +## Editable Fields + +| Field | Aliases | Values | +|-------|---------|--------| +| `name` | - | Any text | +| `art` | wissenschaftlich, scientific | Scientific name | +| `licht` | light | wenig/low, mittel/medium, hell/bright, direkt/direct | +| `wasser` | water | Number of days | +| `feuchtigkeit` | humidity | niedrig/low, mittel/medium, hoch/high | +| `temperatur` | temperature | Any text | +| `erde` | soil | Any text | +| `notizen` | notes | Any text | + +## Example Usage + +``` +# Login +!login max@example.com mypassword + +# Add a new plant +!neu Monstera Deliciosa + +# Edit plant properties +!edit 1 licht hell +!edit 1 wasser 7 +!edit 1 notizen Fensterbank Wohnzimmer + +# Water a plant +!giessen 1 +!giessen 1 Etwas Duenger hinzugefuegt + +# Check watering status +!faellig + +# View watering history +!historie 1 + +# Set watering interval +!intervall 1 5 +``` + +## Plant Health Status + +| Status | Emoji | Description | +|--------|-------|-------------| +| `healthy` | 🌱 | Plant is healthy | +| `needs_attention` | ⚠️ | Plant needs care | +| `sick` | 🥀 | Plant is sick | + +## Environment Variables + +```env +# Server +PORT=3322 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#planta:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Planta Backend +PLANTA_BACKEND_URL=http://localhost:3022 +PLANTA_API_PREFIX=/api + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 +``` + +## Docker + +```bash +# Build locally +docker build -f services/matrix-planta-bot/Dockerfile -t matrix-planta-bot services/matrix-planta-bot + +# Run +docker run -p 3322:3322 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -e PLANTA_BACKEND_URL=http://planta-backend:3022 \ + -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ + -v matrix-planta-bot-data:/app/data \ + matrix-planta-bot +``` + +## Health Check + +```bash +curl http://localhost:3322/health +``` + +## Planta Backend API Endpoints Used + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Health check | +| `/api/plants` | GET | List user's plants | +| `/api/plants` | POST | Create plant | +| `/api/plants/:id` | GET | Get plant details | +| `/api/plants/:id` | PUT | Update plant | +| `/api/plants/:id` | DELETE | Delete plant | +| `/api/watering/upcoming` | GET | Get upcoming waterings | +| `/api/watering/:plantId/water` | POST | Log watering | +| `/api/watering/:plantId` | PUT | Update watering schedule | +| `/api/watering/:plantId/history` | GET | Get watering history | + +## Number-Based Reference System + +The bot uses a number-based reference system for ease of use: +1. User runs `!pflanzen` or `!faellig` to get a list +2. Bot stores the list internally for the user +3. User can reference plants by their list number +4. Numbers are valid until the user runs a new list command + +This allows simple commands like: +- `!pflanze 3` - Show details for plant #3 +- `!giessen 1` - Water plant #1 +- `!edit 2 licht hell` - Set light requirement for plant #2 diff --git a/services/matrix-planta-bot/Dockerfile b/services/matrix-planta-bot/Dockerfile new file mode 100644 index 000000000..78112acf1 --- /dev/null +++ b/services/matrix-planta-bot/Dockerfile @@ -0,0 +1,41 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Copy package files and install production dependencies only +COPY package.json ./ +RUN npm install --omit=dev + +# Copy built application from builder +COPY --from=builder /app/dist ./dist + +# Create data directory +RUN mkdir -p /app/data + +# Expose port +EXPOSE 3322 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3322/health || exit 1 + +# Start the application +CMD ["node", "dist/main.js"] diff --git a/services/matrix-planta-bot/nest-cli.json b/services/matrix-planta-bot/nest-cli.json new file mode 100644 index 000000000..5c06bb8c3 --- /dev/null +++ b/services/matrix-planta-bot/nest-cli.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json-schema.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} diff --git a/services/matrix-planta-bot/package.json b/services/matrix-planta-bot/package.json new file mode 100644 index 000000000..fcda203bd --- /dev/null +++ b/services/matrix-planta-bot/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mana-bots/matrix-planta-bot", + "version": "1.0.0", + "description": "Matrix bot for plant care management", + "main": "dist/main.js", + "scripts": { + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main.js", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.3.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.3.0", + "@nestjs/platform-express": "^10.3.0", + "matrix-bot-sdk": "^0.7.1", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.0", + "@types/node": "^20.10.0", + "typescript": "^5.3.0" + } +} diff --git a/services/matrix-planta-bot/src/app.module.ts b/services/matrix-planta-bot/src/app.module.ts new file mode 100644 index 000000000..e5ea0e3d1 --- /dev/null +++ b/services/matrix-planta-bot/src/app.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { HealthController } from './health.controller'; +import { BotModule } from './bot/bot.module'; +import { PlantaModule } from './planta/planta.module'; +import { SessionModule } from './session/session.module'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + PlantaModule, + SessionModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-planta-bot/src/bot/bot.module.ts b/services/matrix-planta-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..c6e26a634 --- /dev/null +++ b/services/matrix-planta-bot/src/bot/bot.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { PlantaModule } from '../planta/planta.module'; +import { SessionModule } from '../session/session.module'; + +@Module({ + imports: [PlantaModule, SessionModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-planta-bot/src/bot/matrix.service.ts b/services/matrix-planta-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..b2d25fdd7 --- /dev/null +++ b/services/matrix-planta-bot/src/bot/matrix.service.ts @@ -0,0 +1,625 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + AutojoinRoomsMixin, +} from 'matrix-bot-sdk'; +import { PlantaService, Plant } from '../planta/planta.service'; +import { SessionService } from '../session/session.service'; +import { HELP_MESSAGE } from '../config/configuration'; + +@Injectable() +export class MatrixService implements OnModuleInit { + private readonly logger = new Logger(MatrixService.name); + private client: MatrixClient; + private allowedRooms: string[]; + + // Store last shown plants per user for reference by number + private lastPlantsList: Map = new Map(); + + // Field mappings for edit command + private readonly fieldMappings: Record = { + name: 'name', + art: 'scientificName', + wissenschaftlich: 'scientificName', + scientific: 'scientificName', + licht: 'lightRequirements', + light: 'lightRequirements', + wasser: 'wateringFrequencyDays', + water: 'wateringFrequencyDays', + feuchtigkeit: 'humidity', + humidity: 'humidity', + temperatur: 'temperature', + temperature: 'temperature', + erde: 'soilType', + soil: 'soilType', + notizen: 'careNotes', + notes: 'careNotes', + }; + + constructor( + private configService: ConfigService, + private plantaService: PlantaService, + private sessionService: SessionService + ) {} + + async onModuleInit() { + const homeserverUrl = this.configService.get('matrix.homeserverUrl'); + const accessToken = this.configService.get('matrix.accessToken'); + const storagePath = this.configService.get('matrix.storagePath'); + this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; + + if (!accessToken) { + this.logger.warn('No Matrix access token configured, bot disabled'); + return; + } + + const storage = new SimpleFsStorageProvider(storagePath); + this.client = new MatrixClient(homeserverUrl, accessToken, storage); + + AutojoinRoomsMixin.setupOnClient(this.client); + + this.client.on('room.message', this.handleMessage.bind(this)); + + await this.client.start(); + this.logger.log('Matrix Planta Bot started'); + } + + private async handleMessage(roomId: string, event: any) { + if (event.sender === (await this.client.getUserId())) return; + if (event.content?.msgtype !== 'm.text') return; + + const body = event.content.body?.trim(); + if (!body?.startsWith('!')) return; + + // Check allowed rooms + if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) { + return; + } + + const sender = event.sender; + const parts = body.slice(1).split(/\s+/); + const command = parts[0].toLowerCase(); + const args = parts.slice(1); + const argString = args.join(' '); + + try { + switch (command) { + case 'help': + case 'hilfe': + await this.sendHtml(roomId, HELP_MESSAGE); + break; + + case 'login': + await this.handleLogin(roomId, sender, args); + break; + + case 'logout': + this.sessionService.logout(sender); + await this.sendHtml(roomId, '

Erfolgreich abgemeldet.

'); + break; + + case 'status': + await this.handleStatus(roomId, sender); + break; + + case 'pflanzen': + case 'plants': + case 'liste': + await this.handleListPlants(roomId, sender); + break; + + case 'pflanze': + case 'plant': + case 'details': + await this.handlePlantDetails(roomId, sender, args[0]); + break; + + case 'neu': + case 'new': + case 'add': + await this.handleAddPlant(roomId, sender, argString); + break; + + case 'loeschen': + case 'delete': + case 'entfernen': + await this.handleDeletePlant(roomId, sender, args[0]); + break; + + case 'edit': + case 'bearbeiten': + await this.handleEditPlant(roomId, sender, args); + break; + + case 'giessen': + case 'water': + await this.handleWaterPlant(roomId, sender, args[0], args.slice(1).join(' ')); + break; + + case 'faellig': + case 'due': + case 'upcoming': + await this.handleUpcomingWaterings(roomId, sender); + break; + + case 'historie': + case 'history': + case 'verlauf': + await this.handleWateringHistory(roomId, sender, args[0]); + break; + + case 'intervall': + case 'interval': + case 'frequenz': + await this.handleSetInterval(roomId, sender, args[0], args[1]); + break; + + default: + await this.sendHtml( + roomId, + `

Unbekannter Befehl: ${command}. Nutze !help fuer Hilfe.

` + ); + } + } catch (error) { + this.logger.error(`Error handling command ${command}:`, error); + await this.sendHtml(roomId, `

Fehler: ${error.message}

`); + } + } + + private async sendHtml(roomId: string, html: string) { + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: html.replace(/<[^>]*>/g, ''), + format: 'org.matrix.custom.html', + formatted_body: html, + }); + } + + private requireAuth(sender: string): string { + const token = this.sessionService.getToken(sender); + if (!token) { + throw new Error('Nicht angemeldet. Nutze !login email passwort'); + } + return token; + } + + // Auth handlers + private async handleLogin(roomId: string, sender: string, args: string[]) { + if (args.length < 2) { + await this.sendHtml(roomId, '

Verwendung: !login email passwort

'); + return; + } + + const [email, password] = args; + const result = await this.sessionService.login(sender, email, password); + + if (result.success) { + await this.sendHtml(roomId, `

Erfolgreich angemeldet als ${email}

`); + } else { + await this.sendHtml(roomId, `

Login fehlgeschlagen: ${result.error}

`); + } + } + + private async handleStatus(roomId: string, sender: string) { + const backendOk = await this.plantaService.checkHealth(); + const loggedIn = this.sessionService.isLoggedIn(sender); + const sessions = this.sessionService.getSessionCount(); + + await this.sendHtml( + roomId, + `

Planta Bot Status

+
    +
  • Backend: ${backendOk ? 'Online' : 'Offline'}
  • +
  • Angemeldet: ${loggedIn ? 'Ja' : 'Nein'}
  • +
  • Aktive Sessions: ${sessions}
  • +
` + ); + } + + // Plant handlers + private async handleListPlants(roomId: string, sender: string) { + const token = this.requireAuth(sender); + const result = await this.plantaService.getPlants(token); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + const plants = result.data || []; + this.lastPlantsList.set(sender, plants); + + if (plants.length === 0) { + await this.sendHtml( + roomId, + '

Keine Pflanzen vorhanden. Fuege eine mit !neu Name hinzu.

' + ); + return; + } + + let html = '

Deine Pflanzen

    '; + for (const plant of plants) { + const scientific = plant.scientificName ? ` (${plant.scientificName})` : ''; + const health = this.getHealthEmoji(plant.healthStatus); + html += `
  1. ${health} ${plant.name}${scientific}
  2. `; + } + html += '
'; + html += '

Nutze !pflanze [nr] fuer Details oder !faellig fuer Giess-Status

'; + + await this.sendHtml(roomId, html); + } + + private async handlePlantDetails(roomId: string, sender: string, numberStr: string) { + const token = this.requireAuth(sender); + const plant = this.getPlantByNumber(sender, numberStr); + + if (!plant) { + await this.sendHtml( + roomId, + '

Ungueltige Nummer. Nutze zuerst !pflanzen

' + ); + return; + } + + const result = await this.plantaService.getPlant(token, plant.id); + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + const p = result.data!; + const health = this.getHealthEmoji(p.healthStatus); + let html = `

${health} ${p.name}

`; + + if (p.scientificName) html += `

${p.scientificName}

`; + + html += '
    '; + if (p.lightRequirements) html += `
  • Licht: ${this.translateLight(p.lightRequirements)}
  • `; + if (p.wateringFrequencyDays) html += `
  • Giessen: alle ${p.wateringFrequencyDays} Tage
  • `; + if (p.humidity) html += `
  • Feuchtigkeit: ${this.translateHumidity(p.humidity)}
  • `; + if (p.temperature) html += `
  • Temperatur: ${p.temperature}
  • `; + if (p.soilType) html += `
  • Erde: ${p.soilType}
  • `; + if (p.healthStatus) html += `
  • Gesundheit: ${this.translateHealth(p.healthStatus)}
  • `; + if (p.acquiredAt) html += `
  • Erworben: ${new Date(p.acquiredAt).toLocaleDateString('de-DE')}
  • `; + html += '
'; + + if (p.careNotes) { + html += `

Notizen: ${p.careNotes}

`; + } + + await this.sendHtml(roomId, html); + } + + private async handleAddPlant(roomId: string, sender: string, name: string) { + if (!name) { + await this.sendHtml(roomId, '

Verwendung: !neu Pflanzenname

'); + return; + } + + const token = this.requireAuth(sender); + const result = await this.plantaService.createPlant(token, name); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + // Clear cached list + this.lastPlantsList.delete(sender); + await this.sendHtml( + roomId, + `

Pflanze ${result.data!.name} hinzugefuegt!

+

Nutze !edit um Details wie Licht, Wasser etc. zu setzen.

` + ); + } + + private async handleDeletePlant(roomId: string, sender: string, numberStr: string) { + const token = this.requireAuth(sender); + const plant = this.getPlantByNumber(sender, numberStr); + + if (!plant) { + await this.sendHtml( + roomId, + '

Ungueltige Nummer. Nutze zuerst !pflanzen

' + ); + return; + } + + const result = await this.plantaService.deletePlant(token, plant.id); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + // Clear cached list + this.lastPlantsList.delete(sender); + await this.sendHtml(roomId, `

Pflanze ${plant.name} entfernt.

`); + } + + private async handleEditPlant(roomId: string, sender: string, args: string[]) { + if (args.length < 3) { + await this.sendHtml( + roomId, + '

Verwendung: !edit [nr] [feld] [wert]

Felder: name, art, licht, wasser, notizen

' + ); + return; + } + + const token = this.requireAuth(sender); + const plant = this.getPlantByNumber(sender, args[0]); + + if (!plant) { + await this.sendHtml( + roomId, + '

Ungueltige Nummer. Nutze zuerst !pflanzen

' + ); + return; + } + + const fieldInput = args[1].toLowerCase(); + const field = this.fieldMappings[fieldInput]; + const value = args.slice(2).join(' '); + + if (!field) { + await this.sendHtml( + roomId, + `

Unbekanntes Feld: ${fieldInput}

Verfuegbar: name, art, licht, wasser, notizen

` + ); + return; + } + + // Validate and convert values + let updateValue: any = value; + if (field === 'wateringFrequencyDays') { + updateValue = parseInt(value, 10); + if (isNaN(updateValue) || updateValue < 1) { + await this.sendHtml(roomId, '

Wasser-Intervall muss eine positive Zahl sein.

'); + return; + } + } else if (field === 'lightRequirements') { + const lightMap: Record = { + wenig: 'low', low: 'low', gering: 'low', + mittel: 'medium', medium: 'medium', + hell: 'bright', bright: 'bright', viel: 'bright', + direkt: 'direct', direct: 'direct', sonne: 'direct', + }; + updateValue = lightMap[value.toLowerCase()]; + if (!updateValue) { + await this.sendHtml( + roomId, + '

Licht-Werte: wenig/low, mittel/medium, hell/bright, direkt/direct

' + ); + return; + } + } else if (field === 'humidity') { + const humidityMap: Record = { + niedrig: 'low', low: 'low', gering: 'low', trocken: 'low', + mittel: 'medium', medium: 'medium', normal: 'medium', + hoch: 'high', high: 'high', feucht: 'high', + }; + updateValue = humidityMap[value.toLowerCase()]; + if (!updateValue) { + await this.sendHtml( + roomId, + '

Feuchtigkeits-Werte: niedrig/low, mittel/medium, hoch/high

' + ); + return; + } + } + + const result = await this.plantaService.updatePlant(token, plant.id, { + [field]: updateValue, + }); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + await this.sendHtml( + roomId, + `

${plant.name}: ${fieldInput} aktualisiert.

` + ); + } + + // Watering handlers + private async handleWaterPlant(roomId: string, sender: string, numberStr: string, notes?: string) { + const token = this.requireAuth(sender); + const plant = this.getPlantByNumber(sender, numberStr); + + if (!plant) { + await this.sendHtml( + roomId, + '

Ungueltige Nummer. Nutze zuerst !pflanzen

' + ); + return; + } + + const result = await this.plantaService.waterPlant(token, plant.id, notes || undefined); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + let html = `

${plant.name} gegossen!

`; + if (notes) { + html += `

Notiz: ${notes}

`; + } + + await this.sendHtml(roomId, html); + } + + private async handleUpcomingWaterings(roomId: string, sender: string) { + const token = this.requireAuth(sender); + const result = await this.plantaService.getUpcomingWaterings(token); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + const upcoming = result.data || []; + + if (upcoming.length === 0) { + await this.sendHtml(roomId, '

Keine Pflanzen muessen in den naechsten Tagen gegossen werden.

'); + return; + } + + let html = '

Giess-Status

    '; + for (const item of upcoming) { + const status = item.isOverdue + ? `Ueberfaellig (${Math.abs(item.daysUntilWatering)} Tage)` + : item.daysUntilWatering === 0 + ? 'Heute' + : `in ${item.daysUntilWatering} Tag${item.daysUntilWatering > 1 ? 'en' : ''}`; + html += `
  • ${item.plant.name}: ${status}
  • `; + } + html += '
'; + + // Store plants for reference + this.lastPlantsList.set(sender, upcoming.map(u => u.plant)); + + await this.sendHtml(roomId, html); + } + + private async handleWateringHistory(roomId: string, sender: string, numberStr: string) { + const token = this.requireAuth(sender); + const plant = this.getPlantByNumber(sender, numberStr); + + if (!plant) { + await this.sendHtml( + roomId, + '

Ungueltige Nummer. Nutze zuerst !pflanzen

' + ); + return; + } + + const result = await this.plantaService.getWateringHistory(token, plant.id); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + const logs = result.data || []; + + if (logs.length === 0) { + await this.sendHtml( + roomId, + `

${plant.name} wurde noch nie gegossen.

` + ); + return; + } + + let html = `

Giess-Historie: ${plant.name}

    `; + for (const log of logs.slice(0, 10)) { + const date = new Date(log.wateredAt).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + const notes = log.notes ? ` - ${log.notes}` : ''; + html += `
  • ${date}${notes}
  • `; + } + html += '
'; + + if (logs.length > 10) { + html += `

...und ${logs.length - 10} weitere Eintraege

`; + } + + await this.sendHtml(roomId, html); + } + + private async handleSetInterval(roomId: string, sender: string, numberStr: string, daysStr: string) { + if (!numberStr || !daysStr) { + await this.sendHtml( + roomId, + '

Verwendung: !intervall [nr] [tage]

' + ); + return; + } + + const token = this.requireAuth(sender); + const plant = this.getPlantByNumber(sender, numberStr); + + if (!plant) { + await this.sendHtml( + roomId, + '

Ungueltige Nummer. Nutze zuerst !pflanzen

' + ); + return; + } + + const days = parseInt(daysStr, 10); + if (isNaN(days) || days < 1) { + await this.sendHtml(roomId, '

Tage muss eine positive Zahl sein.

'); + return; + } + + const result = await this.plantaService.updateWateringSchedule(token, plant.id, days); + + if (result.error) { + await this.sendHtml(roomId, `

Fehler: ${result.error}

`); + return; + } + + await this.sendHtml( + roomId, + `

Giess-Intervall fuer ${plant.name} auf ${days} Tage gesetzt.

` + ); + } + + // Helper methods + private getPlantByNumber(sender: string, numberStr: string): Plant | null { + const plants = this.lastPlantsList.get(sender); + if (!plants) return null; + + const index = parseInt(numberStr, 10) - 1; + if (isNaN(index) || index < 0 || index >= plants.length) return null; + + return plants[index]; + } + + private getHealthEmoji(status?: string): string { + switch (status) { + case 'healthy': return '🌱'; // Seedling + case 'needs_attention': return '⚠️'; // Warning + case 'sick': return '🤢'; // Wilted + default: return '🌱'; + } + } + + private translateLight(light: string): string { + const map: Record = { + low: 'Wenig Licht', + medium: 'Mittleres Licht', + bright: 'Helles Licht', + direct: 'Direktes Sonnenlicht', + }; + return map[light] || light; + } + + private translateHumidity(humidity: string): string { + const map: Record = { + low: 'Niedrig', + medium: 'Mittel', + high: 'Hoch', + }; + return map[humidity] || humidity; + } + + private translateHealth(health: string): string { + const map: Record = { + healthy: 'Gesund', + needs_attention: 'Braucht Aufmerksamkeit', + sick: 'Krank', + }; + return map[health] || health; + } +} diff --git a/services/matrix-planta-bot/src/config/configuration.ts b/services/matrix-planta-bot/src/config/configuration.ts new file mode 100644 index 000000000..881a7ccbf --- /dev/null +++ b/services/matrix-planta-bot/src/config/configuration.ts @@ -0,0 +1,57 @@ +export default () => ({ + port: parseInt(process.env.PORT, 10) || 3322, + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN, + allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',') || [], + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + planta: { + backendUrl: process.env.PLANTA_BACKEND_URL || 'http://localhost:3022', + apiPrefix: process.env.PLANTA_API_PREFIX || '/api', + }, + auth: { + url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', + }, +}); + +export const HELP_MESSAGE = `

Planta Bot - Befehle

+ +

Authentifizierung

+
    +
  • !login email passwort - Anmelden
  • +
  • !logout - Abmelden
  • +
  • !status - Bot-Status anzeigen
  • +
+ +

Pflanzen verwalten

+
    +
  • !pflanzen - Alle Pflanzen auflisten
  • +
  • !pflanze [nr] - Pflanzendetails anzeigen
  • +
  • !neu Name - Neue Pflanze hinzufuegen
  • +
  • !loeschen [nr] - Pflanze entfernen
  • +
  • !edit [nr] [feld] [wert] - Pflanze bearbeiten
  • +
+ +

Giessen

+
    +
  • !giessen [nr] - Pflanze als gegossen markieren
  • +
  • !giessen [nr] Notiz - Mit Notiz giessen
  • +
  • !faellig - Pflanzen die gegossen werden muessen
  • +
  • !historie [nr] - Giess-Historie anzeigen
  • +
+ +

Pflege-Einstellungen

+
    +
  • !intervall [nr] [tage] - Giess-Intervall aendern
  • +
+ +

Weitere Befehle

+
    +
  • !help - Diese Hilfe anzeigen
  • +
+ +

Bearbeitbare Felder

+

name, art (scientificName), licht (low/medium/bright/direct), wasser (Tage), notizen

+ +

Tipp: Nutze Pflanzennummern aus der zuletzt angezeigten Liste.

`; diff --git a/services/matrix-planta-bot/src/health.controller.ts b/services/matrix-planta-bot/src/health.controller.ts new file mode 100644 index 000000000..3e5e04376 --- /dev/null +++ b/services/matrix-planta-bot/src/health.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { status: 'ok', service: 'matrix-planta-bot' }; + } +} diff --git a/services/matrix-planta-bot/src/main.ts b/services/matrix-planta-bot/src/main.ts new file mode 100644 index 000000000..527c77df7 --- /dev/null +++ b/services/matrix-planta-bot/src/main.ts @@ -0,0 +1,10 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const port = process.env.PORT || 3322; + await app.listen(port); + console.log(`Matrix Planta Bot running on port ${port}`); +} +bootstrap(); diff --git a/services/matrix-planta-bot/src/planta/planta.module.ts b/services/matrix-planta-bot/src/planta/planta.module.ts new file mode 100644 index 000000000..bd941684e --- /dev/null +++ b/services/matrix-planta-bot/src/planta/planta.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PlantaService } from './planta.service'; + +@Module({ + providers: [PlantaService], + exports: [PlantaService], +}) +export class PlantaModule {} diff --git a/services/matrix-planta-bot/src/planta/planta.service.ts b/services/matrix-planta-bot/src/planta/planta.service.ts new file mode 100644 index 000000000..ce866f2ae --- /dev/null +++ b/services/matrix-planta-bot/src/planta/planta.service.ts @@ -0,0 +1,162 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface Plant { + id: string; + name: string; + scientificName?: string; + commonName?: string; + species?: string; + lightRequirements?: 'low' | 'medium' | 'bright' | 'direct'; + wateringFrequencyDays?: number; + humidity?: 'low' | 'medium' | 'high'; + temperature?: string; + soilType?: string; + careNotes?: string; + healthStatus?: 'healthy' | 'needs_attention' | 'sick'; + acquiredAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface WateringSchedule { + id: string; + plantId: string; + frequencyDays: number; + lastWateredAt?: string; + nextWateringAt?: string; + reminderEnabled: boolean; +} + +export interface WateringLog { + id: string; + plantId: string; + wateredAt: string; + notes?: string; +} + +export interface UpcomingWatering { + plant: Plant; + schedule: WateringSchedule; + daysUntilWatering: number; + isOverdue: boolean; +} + +@Injectable() +export class PlantaService { + private readonly logger = new Logger(PlantaService.name); + private backendUrl: string; + private apiPrefix: string; + + constructor(private configService: ConfigService) { + this.backendUrl = this.configService.get('planta.backendUrl') || 'http://localhost:3022'; + this.apiPrefix = this.configService.get('planta.apiPrefix') || '/api'; + } + + private async request( + token: string, + endpoint: string, + options: RequestInit = {} + ): Promise<{ data?: T; error?: string }> { + try { + const url = `${this.backendUrl}${this.apiPrefix}${endpoint}`; + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + ...options.headers, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { error: errorData.message || `Fehler: ${response.status}` }; + } + + const data = await response.json(); + return { data }; + } catch (error) { + this.logger.error(`Request failed: ${endpoint}`, error); + return { error: 'Verbindung zum Backend fehlgeschlagen' }; + } + } + + // Plant operations + async getPlants(token: string): Promise<{ data?: Plant[]; error?: string }> { + return this.request(token, '/plants'); + } + + async getPlant(token: string, plantId: string): Promise<{ data?: Plant; error?: string }> { + return this.request(token, `/plants/${plantId}`); + } + + async createPlant( + token: string, + name: string, + options: Partial = {} + ): Promise<{ data?: Plant; error?: string }> { + return this.request(token, '/plants', { + method: 'POST', + body: JSON.stringify({ name, ...options }), + }); + } + + async updatePlant( + token: string, + plantId: string, + updates: Partial + ): Promise<{ data?: Plant; error?: string }> { + return this.request(token, `/plants/${plantId}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); + } + + async deletePlant(token: string, plantId: string): Promise<{ error?: string }> { + return this.request(token, `/plants/${plantId}`, { method: 'DELETE' }); + } + + // Watering operations + async getUpcomingWaterings(token: string): Promise<{ data?: UpcomingWatering[]; error?: string }> { + return this.request(token, '/watering/upcoming'); + } + + async waterPlant( + token: string, + plantId: string, + notes?: string + ): Promise<{ data?: WateringLog; error?: string }> { + return this.request(token, `/watering/${plantId}/water`, { + method: 'POST', + body: JSON.stringify({ notes }), + }); + } + + async updateWateringSchedule( + token: string, + plantId: string, + frequencyDays: number + ): Promise<{ data?: WateringSchedule; error?: string }> { + return this.request(token, `/watering/${plantId}`, { + method: 'PUT', + body: JSON.stringify({ frequencyDays }), + }); + } + + async getWateringHistory( + token: string, + plantId: string + ): Promise<{ data?: WateringLog[]; error?: string }> { + return this.request(token, `/watering/${plantId}/history`); + } + + async checkHealth(): Promise { + try { + const response = await fetch(`${this.backendUrl}/health`); + return response.ok; + } catch { + return false; + } + } +} diff --git a/services/matrix-planta-bot/src/session/session.module.ts b/services/matrix-planta-bot/src/session/session.module.ts new file mode 100644 index 000000000..834b715eb --- /dev/null +++ b/services/matrix-planta-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-planta-bot/src/session/session.service.ts b/services/matrix-planta-bot/src/session/session.service.ts new file mode 100644 index 000000000..f1bed7852 --- /dev/null +++ b/services/matrix-planta-bot/src/session/session.service.ts @@ -0,0 +1,90 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface UserSession { + token: string; + email: string; + expiresAt: Date; +} + +@Injectable() +export class SessionService { + private readonly logger = new Logger(SessionService.name); + private sessions: Map = new Map(); + private authUrl: string; + + constructor(private configService: ConfigService) { + this.authUrl = this.configService.get('auth.url') || 'http://localhost:3001'; + } + + async login( + matrixUserId: string, + email: string, + password: string + ): Promise<{ success: boolean; error?: string }> { + 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 errorData = await response.json().catch(() => ({})); + return { + success: false, + error: errorData.message || 'Authentifizierung fehlgeschlagen', + }; + } + + const data = await response.json(); + const token = data.accessToken || data.token; + + if (!token) { + return { success: false, error: 'Kein Token erhalten' }; + } + + // Store session (7 days expiry) + this.sessions.set(matrixUserId, { + token, + email, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }); + + this.logger.log(`User ${matrixUserId} logged in as ${email}`); + return { success: true }; + } catch (error) { + this.logger.error(`Login failed for ${matrixUserId}:`, error); + return { + success: false, + error: 'Verbindung zum Auth-Server fehlgeschlagen', + }; + } + } + + logout(matrixUserId: string): void { + this.sessions.delete(matrixUserId); + this.logger.log(`User ${matrixUserId} logged out`); + } + + getToken(matrixUserId: string): string | null { + const session = this.sessions.get(matrixUserId); + + if (!session) return null; + + if (session.expiresAt < new Date()) { + this.sessions.delete(matrixUserId); + return null; + } + + return session.token; + } + + isLoggedIn(matrixUserId: string): boolean { + return this.getToken(matrixUserId) !== null; + } + + getSessionCount(): number { + return this.sessions.size; + } +} diff --git a/services/matrix-planta-bot/tsconfig.json b/services/matrix-planta-bot/tsconfig.json new file mode 100644 index 000000000..94f1e9493 --- /dev/null +++ b/services/matrix-planta-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 + } +}