mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
e5a5e96841
commit
3f336de1b9
17 changed files with 1337 additions and 0 deletions
15
services/matrix-planta-bot/.env.example
Normal file
15
services/matrix-planta-bot/.env.example
Normal file
|
|
@ -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
|
||||
29
services/matrix-planta-bot/.gitignore
vendored
Normal file
29
services/matrix-planta-bot/.gitignore
vendored
Normal file
|
|
@ -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
|
||||
197
services/matrix-planta-bot/CLAUDE.md
Normal file
197
services/matrix-planta-bot/CLAUDE.md
Normal file
|
|
@ -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
|
||||
41
services/matrix-planta-bot/Dockerfile
Normal file
41
services/matrix-planta-bot/Dockerfile
Normal file
|
|
@ -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"]
|
||||
5
services/matrix-planta-bot/nest-cli.json
Normal file
5
services/matrix-planta-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
27
services/matrix-planta-bot/package.json
Normal file
27
services/matrix-planta-bot/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
21
services/matrix-planta-bot/src/app.module.ts
Normal file
21
services/matrix-planta-bot/src/app.module.ts
Normal file
|
|
@ -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 {}
|
||||
11
services/matrix-planta-bot/src/bot/bot.module.ts
Normal file
11
services/matrix-planta-bot/src/bot/bot.module.ts
Normal file
|
|
@ -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 {}
|
||||
625
services/matrix-planta-bot/src/bot/matrix.service.ts
Normal file
625
services/matrix-planta-bot/src/bot/matrix.service.ts
Normal file
|
|
@ -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<string, Plant[]> = new Map();
|
||||
|
||||
// Field mappings for edit command
|
||||
private readonly fieldMappings: Record<string, string> = {
|
||||
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<string>('matrix.homeserverUrl');
|
||||
const accessToken = this.configService.get<string>('matrix.accessToken');
|
||||
const storagePath = this.configService.get<string>('matrix.storagePath');
|
||||
this.allowedRooms = this.configService.get<string[]>('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, '<p>Erfolgreich abgemeldet.</p>');
|
||||
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,
|
||||
`<p>Unbekannter Befehl: <code>${command}</code>. Nutze <code>!help</code> fuer Hilfe.</p>`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error handling command ${command}:`, error);
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${error.message}</p>`);
|
||||
}
|
||||
}
|
||||
|
||||
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 <code>!login email passwort</code>');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// Auth handlers
|
||||
private async handleLogin(roomId: string, sender: string, args: string[]) {
|
||||
if (args.length < 2) {
|
||||
await this.sendHtml(roomId, '<p>Verwendung: <code>!login email passwort</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const [email, password] = args;
|
||||
const result = await this.sessionService.login(sender, email, password);
|
||||
|
||||
if (result.success) {
|
||||
await this.sendHtml(roomId, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
|
||||
} else {
|
||||
await this.sendHtml(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
`<h3>Planta Bot Status</h3>
|
||||
<ul>
|
||||
<li>Backend: ${backendOk ? 'Online' : 'Offline'}</li>
|
||||
<li>Angemeldet: ${loggedIn ? 'Ja' : 'Nein'}</li>
|
||||
<li>Aktive Sessions: ${sessions}</li>
|
||||
</ul>`
|
||||
);
|
||||
}
|
||||
|
||||
// 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, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const plants = result.data || [];
|
||||
this.lastPlantsList.set(sender, plants);
|
||||
|
||||
if (plants.length === 0) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
'<p>Keine Pflanzen vorhanden. Fuege eine mit <code>!neu Name</code> hinzu.</p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<h3>Deine Pflanzen</h3><ol>';
|
||||
for (const plant of plants) {
|
||||
const scientific = plant.scientificName ? ` <em>(${plant.scientificName})</em>` : '';
|
||||
const health = this.getHealthEmoji(plant.healthStatus);
|
||||
html += `<li>${health} <strong>${plant.name}</strong>${scientific}</li>`;
|
||||
}
|
||||
html += '</ol>';
|
||||
html += '<p><em>Nutze <code>!pflanze [nr]</code> fuer Details oder <code>!faellig</code> fuer Giess-Status</em></p>';
|
||||
|
||||
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,
|
||||
'<p>Ungueltige Nummer. Nutze zuerst <code>!pflanzen</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.plantaService.getPlant(token, plant.id);
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const p = result.data!;
|
||||
const health = this.getHealthEmoji(p.healthStatus);
|
||||
let html = `<h3>${health} ${p.name}</h3>`;
|
||||
|
||||
if (p.scientificName) html += `<p><em>${p.scientificName}</em></p>`;
|
||||
|
||||
html += '<ul>';
|
||||
if (p.lightRequirements) html += `<li>Licht: ${this.translateLight(p.lightRequirements)}</li>`;
|
||||
if (p.wateringFrequencyDays) html += `<li>Giessen: alle ${p.wateringFrequencyDays} Tage</li>`;
|
||||
if (p.humidity) html += `<li>Feuchtigkeit: ${this.translateHumidity(p.humidity)}</li>`;
|
||||
if (p.temperature) html += `<li>Temperatur: ${p.temperature}</li>`;
|
||||
if (p.soilType) html += `<li>Erde: ${p.soilType}</li>`;
|
||||
if (p.healthStatus) html += `<li>Gesundheit: ${this.translateHealth(p.healthStatus)}</li>`;
|
||||
if (p.acquiredAt) html += `<li>Erworben: ${new Date(p.acquiredAt).toLocaleDateString('de-DE')}</li>`;
|
||||
html += '</ul>';
|
||||
|
||||
if (p.careNotes) {
|
||||
html += `<p><strong>Notizen:</strong> ${p.careNotes}</p>`;
|
||||
}
|
||||
|
||||
await this.sendHtml(roomId, html);
|
||||
}
|
||||
|
||||
private async handleAddPlant(roomId: string, sender: string, name: string) {
|
||||
if (!name) {
|
||||
await this.sendHtml(roomId, '<p>Verwendung: <code>!neu Pflanzenname</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this.requireAuth(sender);
|
||||
const result = await this.plantaService.createPlant(token, name);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear cached list
|
||||
this.lastPlantsList.delete(sender);
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p>Pflanze <strong>${result.data!.name}</strong> hinzugefuegt!</p>
|
||||
<p><em>Nutze <code>!edit</code> um Details wie Licht, Wasser etc. zu setzen.</em></p>`
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
'<p>Ungueltige Nummer. Nutze zuerst <code>!pflanzen</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.plantaService.deletePlant(token, plant.id);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear cached list
|
||||
this.lastPlantsList.delete(sender);
|
||||
await this.sendHtml(roomId, `<p>Pflanze <strong>${plant.name}</strong> entfernt.</p>`);
|
||||
}
|
||||
|
||||
private async handleEditPlant(roomId: string, sender: string, args: string[]) {
|
||||
if (args.length < 3) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
'<p>Verwendung: <code>!edit [nr] [feld] [wert]</code></p><p>Felder: name, art, licht, wasser, notizen</p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this.requireAuth(sender);
|
||||
const plant = this.getPlantByNumber(sender, args[0]);
|
||||
|
||||
if (!plant) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
'<p>Ungueltige Nummer. Nutze zuerst <code>!pflanzen</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldInput = args[1].toLowerCase();
|
||||
const field = this.fieldMappings[fieldInput];
|
||||
const value = args.slice(2).join(' ');
|
||||
|
||||
if (!field) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p>Unbekanntes Feld: <code>${fieldInput}</code></p><p>Verfuegbar: name, art, licht, wasser, notizen</p>`
|
||||
);
|
||||
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, '<p>Wasser-Intervall muss eine positive Zahl sein.</p>');
|
||||
return;
|
||||
}
|
||||
} else if (field === 'lightRequirements') {
|
||||
const lightMap: Record<string, string> = {
|
||||
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,
|
||||
'<p>Licht-Werte: wenig/low, mittel/medium, hell/bright, direkt/direct</p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (field === 'humidity') {
|
||||
const humidityMap: Record<string, string> = {
|
||||
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,
|
||||
'<p>Feuchtigkeits-Werte: niedrig/low, mittel/medium, hoch/high</p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.plantaService.updatePlant(token, plant.id, {
|
||||
[field]: updateValue,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p><strong>${plant.name}</strong>: ${fieldInput} aktualisiert.</p>`
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
'<p>Ungueltige Nummer. Nutze zuerst <code>!pflanzen</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.plantaService.waterPlant(token, plant.id, notes || undefined);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<p><strong>${plant.name}</strong> gegossen!</p>`;
|
||||
if (notes) {
|
||||
html += `<p><em>Notiz: ${notes}</em></p>`;
|
||||
}
|
||||
|
||||
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, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const upcoming = result.data || [];
|
||||
|
||||
if (upcoming.length === 0) {
|
||||
await this.sendHtml(roomId, '<p>Keine Pflanzen muessen in den naechsten Tagen gegossen werden.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<h3>Giess-Status</h3><ul>';
|
||||
for (const item of upcoming) {
|
||||
const status = item.isOverdue
|
||||
? `<strong style="color: red;">Ueberfaellig (${Math.abs(item.daysUntilWatering)} Tage)</strong>`
|
||||
: item.daysUntilWatering === 0
|
||||
? '<strong style="color: orange;">Heute</strong>'
|
||||
: `in ${item.daysUntilWatering} Tag${item.daysUntilWatering > 1 ? 'en' : ''}`;
|
||||
html += `<li><strong>${item.plant.name}</strong>: ${status}</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
|
||||
// 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,
|
||||
'<p>Ungueltige Nummer. Nutze zuerst <code>!pflanzen</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.plantaService.getWateringHistory(token, plant.id);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const logs = result.data || [];
|
||||
|
||||
if (logs.length === 0) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p><strong>${plant.name}</strong> wurde noch nie gegossen.</p>`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<h3>Giess-Historie: ${plant.name}</h3><ul>`;
|
||||
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 += `<li>${date}${notes}</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
|
||||
if (logs.length > 10) {
|
||||
html += `<p><em>...und ${logs.length - 10} weitere Eintraege</em></p>`;
|
||||
}
|
||||
|
||||
await this.sendHtml(roomId, html);
|
||||
}
|
||||
|
||||
private async handleSetInterval(roomId: string, sender: string, numberStr: string, daysStr: string) {
|
||||
if (!numberStr || !daysStr) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
'<p>Verwendung: <code>!intervall [nr] [tage]</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this.requireAuth(sender);
|
||||
const plant = this.getPlantByNumber(sender, numberStr);
|
||||
|
||||
if (!plant) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
'<p>Ungueltige Nummer. Nutze zuerst <code>!pflanzen</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const days = parseInt(daysStr, 10);
|
||||
if (isNaN(days) || days < 1) {
|
||||
await this.sendHtml(roomId, '<p>Tage muss eine positive Zahl sein.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.plantaService.updateWateringSchedule(token, plant.id, days);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p>Giess-Intervall fuer <strong>${plant.name}</strong> auf ${days} Tage gesetzt.</p>`
|
||||
);
|
||||
}
|
||||
|
||||
// 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<string, string> = {
|
||||
low: 'Wenig Licht',
|
||||
medium: 'Mittleres Licht',
|
||||
bright: 'Helles Licht',
|
||||
direct: 'Direktes Sonnenlicht',
|
||||
};
|
||||
return map[light] || light;
|
||||
}
|
||||
|
||||
private translateHumidity(humidity: string): string {
|
||||
const map: Record<string, string> = {
|
||||
low: 'Niedrig',
|
||||
medium: 'Mittel',
|
||||
high: 'Hoch',
|
||||
};
|
||||
return map[humidity] || humidity;
|
||||
}
|
||||
|
||||
private translateHealth(health: string): string {
|
||||
const map: Record<string, string> = {
|
||||
healthy: 'Gesund',
|
||||
needs_attention: 'Braucht Aufmerksamkeit',
|
||||
sick: 'Krank',
|
||||
};
|
||||
return map[health] || health;
|
||||
}
|
||||
}
|
||||
57
services/matrix-planta-bot/src/config/configuration.ts
Normal file
57
services/matrix-planta-bot/src/config/configuration.ts
Normal file
|
|
@ -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 = `<h2>Planta Bot - Befehle</h2>
|
||||
|
||||
<h3>Authentifizierung</h3>
|
||||
<ul>
|
||||
<li><code>!login email passwort</code> - Anmelden</li>
|
||||
<li><code>!logout</code> - Abmelden</li>
|
||||
<li><code>!status</code> - Bot-Status anzeigen</li>
|
||||
</ul>
|
||||
|
||||
<h3>Pflanzen verwalten</h3>
|
||||
<ul>
|
||||
<li><code>!pflanzen</code> - Alle Pflanzen auflisten</li>
|
||||
<li><code>!pflanze [nr]</code> - Pflanzendetails anzeigen</li>
|
||||
<li><code>!neu Name</code> - Neue Pflanze hinzufuegen</li>
|
||||
<li><code>!loeschen [nr]</code> - Pflanze entfernen</li>
|
||||
<li><code>!edit [nr] [feld] [wert]</code> - Pflanze bearbeiten</li>
|
||||
</ul>
|
||||
|
||||
<h3>Giessen</h3>
|
||||
<ul>
|
||||
<li><code>!giessen [nr]</code> - Pflanze als gegossen markieren</li>
|
||||
<li><code>!giessen [nr] Notiz</code> - Mit Notiz giessen</li>
|
||||
<li><code>!faellig</code> - Pflanzen die gegossen werden muessen</li>
|
||||
<li><code>!historie [nr]</code> - Giess-Historie anzeigen</li>
|
||||
</ul>
|
||||
|
||||
<h3>Pflege-Einstellungen</h3>
|
||||
<ul>
|
||||
<li><code>!intervall [nr] [tage]</code> - Giess-Intervall aendern</li>
|
||||
</ul>
|
||||
|
||||
<h3>Weitere Befehle</h3>
|
||||
<ul>
|
||||
<li><code>!help</code> - Diese Hilfe anzeigen</li>
|
||||
</ul>
|
||||
|
||||
<h3>Bearbeitbare Felder</h3>
|
||||
<p><code>name</code>, <code>art</code> (scientificName), <code>licht</code> (low/medium/bright/direct), <code>wasser</code> (Tage), <code>notizen</code></p>
|
||||
|
||||
<p><em>Tipp: Nutze Pflanzennummern aus der zuletzt angezeigten Liste.</em></p>`;
|
||||
9
services/matrix-planta-bot/src/health.controller.ts
Normal file
9
services/matrix-planta-bot/src/health.controller.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok', service: 'matrix-planta-bot' };
|
||||
}
|
||||
}
|
||||
10
services/matrix-planta-bot/src/main.ts
Normal file
10
services/matrix-planta-bot/src/main.ts
Normal file
|
|
@ -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();
|
||||
8
services/matrix-planta-bot/src/planta/planta.module.ts
Normal file
8
services/matrix-planta-bot/src/planta/planta.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PlantaService } from './planta.service';
|
||||
|
||||
@Module({
|
||||
providers: [PlantaService],
|
||||
exports: [PlantaService],
|
||||
})
|
||||
export class PlantaModule {}
|
||||
162
services/matrix-planta-bot/src/planta/planta.service.ts
Normal file
162
services/matrix-planta-bot/src/planta/planta.service.ts
Normal file
|
|
@ -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<string>('planta.backendUrl') || 'http://localhost:3022';
|
||||
this.apiPrefix = this.configService.get<string>('planta.apiPrefix') || '/api';
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
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<Plant[]>(token, '/plants');
|
||||
}
|
||||
|
||||
async getPlant(token: string, plantId: string): Promise<{ data?: Plant; error?: string }> {
|
||||
return this.request<Plant>(token, `/plants/${plantId}`);
|
||||
}
|
||||
|
||||
async createPlant(
|
||||
token: string,
|
||||
name: string,
|
||||
options: Partial<Plant> = {}
|
||||
): Promise<{ data?: Plant; error?: string }> {
|
||||
return this.request<Plant>(token, '/plants', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, ...options }),
|
||||
});
|
||||
}
|
||||
|
||||
async updatePlant(
|
||||
token: string,
|
||||
plantId: string,
|
||||
updates: Partial<Plant>
|
||||
): Promise<{ data?: Plant; error?: string }> {
|
||||
return this.request<Plant>(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<UpcomingWatering[]>(token, '/watering/upcoming');
|
||||
}
|
||||
|
||||
async waterPlant(
|
||||
token: string,
|
||||
plantId: string,
|
||||
notes?: string
|
||||
): Promise<{ data?: WateringLog; error?: string }> {
|
||||
return this.request<WateringLog>(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<WateringSchedule>(token, `/watering/${plantId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ frequencyDays }),
|
||||
});
|
||||
}
|
||||
|
||||
async getWateringHistory(
|
||||
token: string,
|
||||
plantId: string
|
||||
): Promise<{ data?: WateringLog[]; error?: string }> {
|
||||
return this.request<WateringLog[]>(token, `/watering/${plantId}/history`);
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.backendUrl}/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
services/matrix-planta-bot/src/session/session.module.ts
Normal file
8
services/matrix-planta-bot/src/session/session.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Module({
|
||||
providers: [SessionService],
|
||||
exports: [SessionService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
90
services/matrix-planta-bot/src/session/session.service.ts
Normal file
90
services/matrix-planta-bot/src/session/session.service.ts
Normal file
|
|
@ -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<string, UserSession> = new Map();
|
||||
private authUrl: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.authUrl = this.configService.get<string>('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;
|
||||
}
|
||||
}
|
||||
22
services/matrix-planta-bot/tsconfig.json
Normal file
22
services/matrix-planta-bot/tsconfig.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue