From 64535373ac6c75bd2e15c017573033d14c87ee63 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:20:11 +0100 Subject: [PATCH] feat(matrix-contacts-bot): add Matrix bot for contact management - List, search, and view contact details - Create, edit, and delete contacts - Toggle favorites and archive status - Number-based reference system for easy commands - German and English command aliases - Login/logout via mana-core-auth Co-Authored-By: Claude Opus 4.5 --- pnpm-lock.yaml | 34 + services/matrix-contacts-bot/.env.example | 15 + services/matrix-contacts-bot/.gitignore | 29 + services/matrix-contacts-bot/CLAUDE.md | 186 +++++ services/matrix-contacts-bot/Dockerfile | 41 + services/matrix-contacts-bot/nest-cli.json | 8 + services/matrix-contacts-bot/package.json | 39 + .../matrix-contacts-bot/src/app.module.ts | 17 + .../matrix-contacts-bot/src/bot/bot.module.ts | 11 + .../src/bot/matrix.service.ts | 733 ++++++++++++++++++ .../src/config/configuration.ts | 48 ++ .../src/contacts/contacts.module.ts | 8 + .../src/contacts/contacts.service.ts | 252 ++++++ .../src/health.controller.ts | 13 + services/matrix-contacts-bot/src/main.ts | 17 + .../src/session/session.module.ts | 8 + .../src/session/session.service.ts | 90 +++ services/matrix-contacts-bot/tsconfig.json | 23 + 18 files changed, 1572 insertions(+) create mode 100644 services/matrix-contacts-bot/.env.example create mode 100644 services/matrix-contacts-bot/.gitignore create mode 100644 services/matrix-contacts-bot/CLAUDE.md create mode 100644 services/matrix-contacts-bot/Dockerfile create mode 100644 services/matrix-contacts-bot/nest-cli.json create mode 100644 services/matrix-contacts-bot/package.json create mode 100644 services/matrix-contacts-bot/src/app.module.ts create mode 100644 services/matrix-contacts-bot/src/bot/bot.module.ts create mode 100644 services/matrix-contacts-bot/src/bot/matrix.service.ts create mode 100644 services/matrix-contacts-bot/src/config/configuration.ts create mode 100644 services/matrix-contacts-bot/src/contacts/contacts.module.ts create mode 100644 services/matrix-contacts-bot/src/contacts/contacts.service.ts create mode 100644 services/matrix-contacts-bot/src/health.controller.ts create mode 100644 services/matrix-contacts-bot/src/main.ts create mode 100644 services/matrix-contacts-bot/src/session/session.module.ts create mode 100644 services/matrix-contacts-bot/src/session/session.service.ts create mode 100644 services/matrix-contacts-bot/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb59997cb..1266378e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5800,6 +5800,40 @@ importers: specifier: ^5.7.3 version: 5.9.3 + services/matrix-contacts-bot: + dependencies: + '@nestjs/common': + specifier: ^10.4.15 + version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^3.3.0 + version: 3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.4.15 + version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^10.4.15 + version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) + matrix-bot-sdk: + specifier: ^0.7.1 + version: 0.7.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + devDependencies: + '@nestjs/cli': + specifier: ^10.4.9 + version: 10.4.9 + '@types/node': + specifier: ^22.10.2 + version: 22.19.1 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + services/matrix-mana-bot: dependencies: '@manacore/bot-services': diff --git a/services/matrix-contacts-bot/.env.example b/services/matrix-contacts-bot/.env.example new file mode 100644 index 000000000..4b5853781 --- /dev/null +++ b/services/matrix-contacts-bot/.env.example @@ -0,0 +1,15 @@ +# Server +PORT=3320 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#contacts:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Contacts Backend +CONTACTS_BACKEND_URL=http://localhost:3015 +CONTACTS_API_PREFIX=/api/v1 + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 diff --git a/services/matrix-contacts-bot/.gitignore b/services/matrix-contacts-bot/.gitignore new file mode 100644 index 000000000..2d508e5ec --- /dev/null +++ b/services/matrix-contacts-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-contacts-bot/CLAUDE.md b/services/matrix-contacts-bot/CLAUDE.md new file mode 100644 index 000000000..5cb9f9279 --- /dev/null +++ b/services/matrix-contacts-bot/CLAUDE.md @@ -0,0 +1,186 @@ +# Matrix Contacts Bot - Claude Code Guidelines + +## Overview + +Matrix Contacts Bot provides contact management via Matrix chat. It integrates with the Contacts backend for full CRUD operations, search, favorites, and archiving. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Backend**: Contacts API (port 3015) +- **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-contacts-bot/ +├── src/ +│ ├── main.ts # Application entry point (port 3320) +│ ├── 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 +│ ├── contacts/ +│ │ ├── contacts.module.ts +│ │ └── contacts.service.ts # Contacts 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 | +| `!kontakte` | contacts, liste | List all contacts | +| `!suche [text]` | search | Search contacts | +| `!favoriten` | favorites | Show favorites | +| `!kontakt [nr]` | contact, details | Show contact details | +| `!neu Vorname [Nachname]` | new, add | Create new contact | +| `!edit [nr] [feld] [wert]` | bearbeiten | Edit contact field | +| `!loeschen [nr]` | delete | Delete contact | +| `!fav [nr]` | favorit | Toggle favorite | +| `!archiv [nr]` | archive | Toggle archive | +| `!login email pass` | - | Login | +| `!logout` | - | Logout | +| `!status` | - | Bot status | + +## Editable Fields + +| Field | Aliases | Description | +|-------|---------|-------------| +| `email` | - | Email address | +| `phone` | telefon | Phone number | +| `mobile` | mobil, handy | Mobile number | +| `company` | firma | Company name | +| `job` | jobtitle, beruf | Job title | +| `website` | web | Website URL | +| `street` | strasse | Street address | +| `city` | stadt | City | +| `zip` | plz | Postal code | +| `country` | land | Country | +| `notes` | notizen | Notes | +| `birthday` | geburtstag | Birthday (YYYY-MM-DD) | + +## Example Usage + +``` +# Create a contact +!neu Max Mustermann + +# Add email +!edit 1 email max@example.com + +# Add phone +!edit 1 phone +49 123 456789 + +# Mark as favorite +!fav 1 + +# Search +!suche Muster +``` + +## Environment Variables + +```env +# Server +PORT=3320 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#contacts:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Contacts Backend +CONTACTS_BACKEND_URL=http://localhost:3015 +CONTACTS_API_PREFIX=/api/v1 + +# Mana Core Auth +MANA_CORE_AUTH_URL=http://localhost:3001 +``` + +## Docker + +```bash +# Build locally +docker build -f services/matrix-contacts-bot/Dockerfile -t matrix-contacts-bot services/matrix-contacts-bot + +# Run +docker run -p 3320:3320 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -e CONTACTS_BACKEND_URL=http://contacts-backend:3015 \ + -e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \ + -v matrix-contacts-bot-data:/app/data \ + matrix-contacts-bot +``` + +## Health Check + +```bash +curl http://localhost:3320/health +``` + +## Getting a Matrix Access Token + +```bash +# Create bot user first, then login +curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "m.login.password", + "user": "contacts-bot", + "password": "your-password" + }' + +# Response contains: {"access_token": "syt_xxx", ...} +``` + +## Contacts Backend API Endpoints Used + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Health check | +| `/api/v1/contacts` | GET | List contacts with filters | +| `/api/v1/contacts` | POST | Create contact | +| `/api/v1/contacts/:id` | GET | Get contact details | +| `/api/v1/contacts/:id` | PATCH | Update contact | +| `/api/v1/contacts/:id` | DELETE | Delete contact | +| `/api/v1/contacts/:id/favorite` | POST | Toggle favorite | +| `/api/v1/contacts/:id/archive` | POST | Toggle archive | + +## Number-Based Reference System + +The bot uses a number-based reference system for ease of use: +1. User runs `!kontakte` or `!suche` to get a list +2. Bot stores the list internally for the user +3. User can reference contacts by their list number +4. Numbers are valid until the user runs a new list command + +This allows simple commands like: +- `!kontakt 3` - Show details for contact #3 in the list +- `!edit 1 email new@email.com` - Edit contact #1 +- `!fav 2` - Toggle favorite for contact #2 diff --git a/services/matrix-contacts-bot/Dockerfile b/services/matrix-contacts-bot/Dockerfile new file mode 100644 index 000000000..c4e3a8392 --- /dev/null +++ b/services/matrix-contacts-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 3320 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3320/health || exit 1 + +# Start the application +CMD ["node", "dist/main.js"] diff --git a/services/matrix-contacts-bot/nest-cli.json b/services/matrix-contacts-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/matrix-contacts-bot/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/matrix-contacts-bot/package.json b/services/matrix-contacts-bot/package.json new file mode 100644 index 000000000..f4673c293 --- /dev/null +++ b/services/matrix-contacts-bot/package.json @@ -0,0 +1,39 @@ +{ + "name": "@manacore/matrix-contacts-bot", + "version": "1.0.0", + "description": "Matrix bot for contact management via Contacts backend", + "private": true, + "pnpm": { + "neverBuiltDependencies": [ + "@matrix-org/matrix-sdk-crypto-nodejs" + ], + "overrides": { + "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" + } + }, + "overrides": { + "@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0" + }, + "scripts": { + "prebuild": "rm -rf dist || true", + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "matrix-bot-sdk": "^0.7.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } +} diff --git a/services/matrix-contacts-bot/src/app.module.ts b/services/matrix-contacts-bot/src/app.module.ts new file mode 100644 index 000000000..09bbe3a8c --- /dev/null +++ b/services/matrix-contacts-bot/src/app.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import configuration from './config/configuration'; +import { BotModule } from './bot/bot.module'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-contacts-bot/src/bot/bot.module.ts b/services/matrix-contacts-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..b85318815 --- /dev/null +++ b/services/matrix-contacts-bot/src/bot/bot.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { ContactsModule } from '../contacts/contacts.module'; +import { SessionModule } from '../session/session.module'; + +@Module({ + imports: [ContactsModule, SessionModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-contacts-bot/src/bot/matrix.service.ts b/services/matrix-contacts-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..a401e5622 --- /dev/null +++ b/services/matrix-contacts-bot/src/bot/matrix.service.ts @@ -0,0 +1,733 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + RichConsoleLogger, + LogService, + LogLevel, +} from 'matrix-bot-sdk'; +import { ContactsService, Contact } from '../contacts/contacts.service'; +import { SessionService } from '../session/session.service'; +import { HELP_MESSAGE } from '../config/configuration'; + +// Natural language keywords +const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [ + { keywords: ['hilfe', 'help', 'befehle', 'commands'], command: 'help' }, + { keywords: ['kontakte', 'contacts', 'alle'], command: 'kontakte' }, + { keywords: ['favoriten', 'favorites', 'favs'], command: 'favoriten' }, + { keywords: ['suche', 'search', 'finde'], command: 'suche' }, + { keywords: ['status', 'info'], command: 'status' }, +]; + +@Injectable() +export class MatrixService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MatrixService.name); + private client!: MatrixClient; + private readonly allowedRooms: string[]; + private botUserId: string = ''; + + // Store last shown contacts per user for reference by number + private lastContactsList: Map = new Map(); + + constructor( + private configService: ConfigService, + private contactsService: ContactsService, + private sessionService: SessionService + ) { + this.allowedRooms = this.configService.get('matrix.allowedRooms') || []; + } + + async onModuleInit() { + const homeserverUrl = this.configService.get('matrix.homeserverUrl'); + const accessToken = this.configService.get('matrix.accessToken'); + const storagePath = this.configService.get('matrix.storagePath'); + + if (!accessToken) { + this.logger.error('MATRIX_ACCESS_TOKEN is required'); + return; + } + + LogService.setLogger(new RichConsoleLogger()); + LogService.setLevel(LogLevel.INFO); + + const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); + this.client = new MatrixClient(homeserverUrl!, accessToken, storage); + + this.client.on('room.invite', async (roomId: string) => { + this.logger.log(`Invited to room ${roomId}, joining...`); + await this.client.joinRoom(roomId); + + setTimeout(async () => { + try { + await this.sendBotIntroduction(roomId); + } catch (error) { + this.logger.error(`Failed to send introduction to ${roomId}:`, error); + } + }, 2000); + }); + + this.botUserId = await this.client.getUserId(); + this.logger.log(`Bot user ID: ${this.botUserId}`); + + this.client.on('room.message', this.handleRoomMessage.bind(this)); + + await this.client.start(); + this.logger.log('Matrix Contacts Bot started successfully'); + } + + async onModuleDestroy() { + if (this.client) { + await this.client.stop(); + this.logger.log('Matrix bot stopped'); + } + } + + private async sendBotIntroduction(roomId: string) { + const introText = `**Contacts Bot - Kontaktverwaltung** + +Ich helfe dir, deine Kontakte zu verwalten! + +**Schnellstart:** +\`!kontakte\` - Alle Kontakte anzeigen +\`!suche Max\` - Kontakte suchen +\`!neu Vorname Nachname\` - Neuen Kontakt + +Sag "hilfe" fur alle Befehle!`; + + await this.sendMessage(roomId, introText); + } + + private isRoomAllowed(roomId: string): boolean { + if (this.allowedRooms.length === 0) return true; + return this.allowedRooms.some((allowed) => roomId === allowed || roomId.includes(allowed)); + } + + private async handleRoomMessage(roomId: string, event: any) { + if (event.sender === this.botUserId) return; + + if (!this.isRoomAllowed(roomId)) { + this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`); + return; + } + + const content = event.content as { msgtype?: string; body?: string }; + + if (content.msgtype !== 'm.text') return; + + const body = content.body; + if (!body) return; + + this.logger.log(`Message from ${event.sender} in ${roomId}: ${body.substring(0, 50)}...`); + + if (body.startsWith('!')) { + await this.handleCommand(roomId, event.sender, body); + return; + } + + const keywordCommand = this.detectKeywordCommand(body); + if (keywordCommand) { + await this.handleCommand(roomId, event.sender, `!${keywordCommand}`); + return; + } + } + + private detectKeywordCommand(message: string): string | null { + const lowerMessage = message.toLowerCase().trim(); + + if (lowerMessage.length > 30) return null; + + for (const { keywords, command } of KEYWORD_COMMANDS) { + for (const keyword of keywords) { + if (lowerMessage === keyword || lowerMessage.startsWith(keyword + ' ')) { + return command; + } + } + } + return null; + } + + private async handleCommand(roomId: string, sender: string, body: string) { + const [command, ...args] = body.slice(1).split(' '); + const argString = args.join(' '); + + switch (command.toLowerCase()) { + case 'help': + case 'hilfe': + case 'start': + await this.sendHelp(roomId); + break; + + case 'kontakte': + case 'contacts': + case 'liste': + case 'list': + await this.handleListContacts(roomId, sender); + break; + + case 'suche': + case 'search': + await this.handleSearch(roomId, sender, argString); + break; + + case 'favoriten': + case 'favorites': + case 'favs': + await this.handleFavorites(roomId, sender); + break; + + case 'kontakt': + case 'contact': + case 'details': + await this.handleContactDetails(roomId, sender, args); + break; + + case 'neu': + case 'new': + case 'add': + await this.handleCreateContact(roomId, sender, args); + break; + + case 'edit': + case 'bearbeiten': + await this.handleEditContact(roomId, sender, args); + break; + + case 'loeschen': + case 'delete': + case 'del': + await this.handleDeleteContact(roomId, sender, args); + break; + + case 'fav': + case 'favorit': + await this.handleToggleFavorite(roomId, sender, args); + break; + + case 'archiv': + case 'archive': + await this.handleToggleArchive(roomId, sender, args); + break; + + case 'login': + await this.handleLogin(roomId, sender, args); + break; + + case 'logout': + this.sessionService.logout(sender); + await this.sendMessage(roomId, 'Du wurdest abgemeldet.'); + break; + + case 'status': + await this.handleStatus(roomId, sender); + break; + + case 'pin': + await this.pinHelpMessage(roomId); + break; + + default: + await this.sendMessage( + roomId, + `Unbekannter Befehl: !${command}\n\nSag "hilfe" fur alle Befehle.` + ); + } + } + + private async handleListContacts(roomId: string, sender: string) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + try { + const result = await this.contactsService.getContacts(token, { limit: 20 }); + const contacts = result.contacts; + + if (contacts.length === 0) { + await this.sendMessage( + roomId, + `Du hast noch keine Kontakte.\n\nNutze \`!neu Vorname Nachname\` um einen zu erstellen.` + ); + return; + } + + // Store for reference + this.lastContactsList.set(sender, contacts); + + let text = `**Deine Kontakte (${result.total}):**\n\n`; + for (let i = 0; i < contacts.length; i++) { + const c = contacts[i]; + const name = c.displayName || `${c.firstName || ''} ${c.lastName || ''}`.trim() || 'Unbenannt'; + const favIcon = c.isFavorite ? ' ★' : ''; + const company = c.company ? ` - ${c.company}` : ''; + text += `**${i + 1}.** ${name}${favIcon}${company}\n`; + } + + if (result.total > 20) { + text += `\n_...und ${result.total - 20} weitere_`; + } + + text += `\n\nNutze \`!kontakt [nr]\` fur Details.`; + + await this.sendMessage(roomId, text); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleSearch(roomId: string, sender: string, searchTerm: string) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + if (!searchTerm.trim()) { + await this.sendMessage(roomId, `**Verwendung:** \`!suche [text]\`\n\nBeispiel: \`!suche Max\``); + return; + } + + try { + const result = await this.contactsService.getContacts(token, { search: searchTerm, limit: 20 }); + const contacts = result.contacts; + + if (contacts.length === 0) { + await this.sendMessage(roomId, `Keine Kontakte gefunden fur: "${searchTerm}"`); + return; + } + + this.lastContactsList.set(sender, contacts); + + let text = `**Suchergebnisse fur "${searchTerm}" (${contacts.length}):**\n\n`; + for (let i = 0; i < contacts.length; i++) { + const c = contacts[i]; + const name = c.displayName || `${c.firstName || ''} ${c.lastName || ''}`.trim() || 'Unbenannt'; + const favIcon = c.isFavorite ? ' ★' : ''; + const email = c.email ? ` (${c.email})` : ''; + text += `**${i + 1}.** ${name}${favIcon}${email}\n`; + } + + await this.sendMessage(roomId, text); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleFavorites(roomId: string, sender: string) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + try { + const result = await this.contactsService.getContacts(token, { isFavorite: true, limit: 20 }); + const contacts = result.contacts; + + if (contacts.length === 0) { + await this.sendMessage( + roomId, + `Du hast noch keine Favoriten.\n\nNutze \`!fav [nr]\` um einen Kontakt als Favorit zu markieren.` + ); + return; + } + + this.lastContactsList.set(sender, contacts); + + let text = `**Deine Favoriten (${contacts.length}):**\n\n`; + for (let i = 0; i < contacts.length; i++) { + const c = contacts[i]; + const name = c.displayName || `${c.firstName || ''} ${c.lastName || ''}`.trim() || 'Unbenannt'; + const phone = c.phone || c.mobile || ''; + text += `**${i + 1}.** ★ ${name}${phone ? ` - ${phone}` : ''}\n`; + } + + await this.sendMessage(roomId, text); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleContactDetails(roomId: string, sender: string, args: string[]) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + if (args.length < 1) { + await this.sendMessage(roomId, `**Verwendung:** \`!kontakt [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`); + return; + } + + const index = parseInt(args[0], 10); + if (isNaN(index) || index < 1) { + await this.sendMessage(roomId, `Ungultige Nummer.`); + return; + } + + const contacts = this.lastContactsList.get(sender); + if (!contacts || index > contacts.length) { + await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); + return; + } + + const contact = contacts[index - 1]; + + try { + const details = await this.contactsService.getContact(token, contact.id); + + let text = `**${details.displayName || `${details.firstName || ''} ${details.lastName || ''}`.trim()}**\n\n`; + + if (details.isFavorite) text += `★ Favorit\n\n`; + + if (details.company || details.jobTitle) { + const job = [details.jobTitle, details.company].filter(Boolean).join(' bei '); + text += `**Beruf:** ${job}\n`; + } + + if (details.email) text += `**E-Mail:** ${details.email}\n`; + if (details.phone) text += `**Telefon:** ${details.phone}\n`; + if (details.mobile) text += `**Mobil:** ${details.mobile}\n`; + + if (details.street || details.city) { + const address = [details.street, `${details.postalCode || ''} ${details.city || ''}`.trim(), details.country] + .filter(Boolean) + .join(', '); + if (address) text += `**Adresse:** ${address}\n`; + } + + if (details.website) text += `**Website:** ${details.website}\n`; + if (details.birthday) text += `**Geburtstag:** ${new Date(details.birthday).toLocaleDateString('de-DE')}\n`; + if (details.notes) text += `\n**Notizen:** ${details.notes}\n`; + + await this.sendMessage(roomId, text); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleCreateContact(roomId: string, sender: string, args: string[]) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + if (args.length < 1) { + await this.sendMessage( + roomId, + `**Verwendung:** \`!neu Vorname [Nachname]\`\n\nBeispiel: \`!neu Max Mustermann\`` + ); + return; + } + + const firstName = args[0]; + const lastName = args.slice(1).join(' ') || undefined; + + try { + const contact = await this.contactsService.createContact(token, { + firstName, + lastName, + }); + + const name = contact.displayName || `${firstName} ${lastName || ''}`.trim(); + await this.sendMessage( + roomId, + `Kontakt **${name}** erstellt!\n\nNutze \`!kontakte\` um die Liste zu sehen oder \`!edit\` um weitere Daten hinzuzufugen.` + ); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleEditContact(roomId: string, sender: string, args: string[]) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + if (args.length < 3) { + await this.sendMessage( + roomId, + `**Verwendung:** \`!edit [nr] [feld] [wert]\`\n\n**Felder:** email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday\n\n**Beispiel:** \`!edit 1 email max@example.com\`` + ); + return; + } + + const index = parseInt(args[0], 10); + const field = args[1].toLowerCase(); + const value = args.slice(2).join(' '); + + if (isNaN(index) || index < 1) { + await this.sendMessage(roomId, `Ungultige Nummer.`); + return; + } + + const contacts = this.lastContactsList.get(sender); + if (!contacts || index > contacts.length) { + await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); + return; + } + + const contact = contacts[index - 1]; + + const fieldMap: Record = { + email: 'email', + phone: 'phone', + telefon: 'phone', + mobile: 'mobile', + mobil: 'mobile', + handy: 'mobile', + company: 'company', + firma: 'company', + job: 'jobTitle', + jobtitle: 'jobTitle', + beruf: 'jobTitle', + website: 'website', + web: 'website', + street: 'street', + strasse: 'street', + city: 'city', + stadt: 'city', + zip: 'postalCode', + plz: 'postalCode', + country: 'country', + land: 'country', + notes: 'notes', + notizen: 'notes', + birthday: 'birthday', + geburtstag: 'birthday', + firstname: 'firstName', + vorname: 'firstName', + lastname: 'lastName', + nachname: 'lastName', + }; + + const mappedField = fieldMap[field]; + if (!mappedField) { + await this.sendMessage(roomId, `Unbekanntes Feld: ${field}\n\n**Gultige Felder:** email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday`); + return; + } + + try { + const updated = await this.contactsService.updateContact(token, contact.id, { + [mappedField]: value, + }); + + const name = updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim(); + await this.sendMessage(roomId, `Kontakt **${name}** aktualisiert!\n\n**${field}:** ${value}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleDeleteContact(roomId: string, sender: string, args: string[]) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + if (args.length < 1) { + await this.sendMessage(roomId, `**Verwendung:** \`!loeschen [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`); + return; + } + + const index = parseInt(args[0], 10); + if (isNaN(index) || index < 1) { + await this.sendMessage(roomId, `Ungultige Nummer.`); + return; + } + + const contacts = this.lastContactsList.get(sender); + if (!contacts || index > contacts.length) { + await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); + return; + } + + const contact = contacts[index - 1]; + const name = contact.displayName || `${contact.firstName || ''} ${contact.lastName || ''}`.trim(); + + try { + await this.contactsService.deleteContact(token, contact.id); + await this.sendMessage(roomId, `Kontakt **${name}** geloscht.`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleToggleFavorite(roomId: string, sender: string, args: string[]) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + if (args.length < 1) { + await this.sendMessage(roomId, `**Verwendung:** \`!fav [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`); + return; + } + + const index = parseInt(args[0], 10); + if (isNaN(index) || index < 1) { + await this.sendMessage(roomId, `Ungultige Nummer.`); + return; + } + + const contacts = this.lastContactsList.get(sender); + if (!contacts || index > contacts.length) { + await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); + return; + } + + const contact = contacts[index - 1]; + + try { + const updated = await this.contactsService.toggleFavorite(token, contact.id); + const name = updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim(); + const status = updated.isFavorite ? 'als Favorit markiert ★' : 'aus Favoriten entfernt'; + await this.sendMessage(roomId, `**${name}** ${status}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async handleToggleArchive(roomId: string, sender: string, args: string[]) { + const token = this.sessionService.getToken(sender); + if (!token) { + await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`); + return; + } + + if (args.length < 1) { + await this.sendMessage(roomId, `**Verwendung:** \`!archiv [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`); + return; + } + + const index = parseInt(args[0], 10); + if (isNaN(index) || index < 1) { + await this.sendMessage(roomId, `Ungultige Nummer.`); + return; + } + + const contacts = this.lastContactsList.get(sender); + if (!contacts || index > contacts.length) { + await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`); + return; + } + + const contact = contacts[index - 1]; + + try { + const updated = await this.contactsService.toggleArchive(token, contact.id); + const name = updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim(); + const status = updated.isArchived ? 'archiviert' : 'aus dem Archiv geholt'; + await this.sendMessage(roomId, `**${name}** ${status}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await this.sendMessage(roomId, `Fehler: ${errorMsg}`); + } + } + + private async sendHelp(roomId: string) { + await this.sendMessage(roomId, HELP_MESSAGE); + } + + private async handleLogin(roomId: string, sender: string, args: string[]) { + if (args.length < 2) { + await this.sendMessage( + roomId, + `**Verwendung:** \`!login email passwort\`\n\nBeispiel: \`!login nutzer@example.com meinpasswort\`` + ); + return; + } + + const [email, password] = args; + + await this.sendMessage(roomId, 'Anmeldung lauft...'); + + const result = await this.sessionService.login(sender, email, password); + + if (result.success) { + await this.sendMessage( + roomId, + `Erfolgreich angemeldet!\n\nNutze \`!kontakte\` um deine Kontakte zu sehen.` + ); + } else { + await this.sendMessage(roomId, `Anmeldung fehlgeschlagen: ${result.error}`); + } + } + + private async handleStatus(roomId: string, sender: string) { + const backendHealthy = await this.contactsService.checkHealth(); + const isLoggedIn = this.sessionService.isLoggedIn(sender); + const sessionCount = this.sessionService.getSessionCount(); + + const statusText = `**Contacts Bot Status** + +**Backend:** ${backendHealthy ? 'Online' : 'Offline'} +**Dein Status:** ${isLoggedIn ? 'Angemeldet' : 'Nicht angemeldet'} +**Aktive Sessions:** ${sessionCount} + +${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`; + + await this.sendMessage(roomId, statusText); + } + + private async pinHelpMessage(roomId: string) { + try { + const htmlBody = this.markdownToHtml(HELP_MESSAGE); + + const eventId = await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: HELP_MESSAGE, + format: 'org.matrix.custom.html', + formatted_body: htmlBody, + }); + + await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', { + pinned: [eventId], + }); + + this.logger.log(`Pinned help message in room ${roomId}`); + } catch (error) { + this.logger.error(`Failed to pin help message:`, error); + await this.sendMessage(roomId, 'Fehler beim Pinnen der Hilfe.'); + } + } + + private async sendMessage(roomId: string, message: string) { + const htmlBody = this.markdownToHtml(message); + + await this.client.sendMessage(roomId, { + msgtype: 'm.text', + body: message, + format: 'org.matrix.custom.html', + formatted_body: htmlBody, + }); + } + + private markdownToHtml(markdown: string): string { + return ( + markdown + .replace(/```(\w+)?\n([\s\S]*?)```/g, '
$2
') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/_([^_]+)_/g, '$1') + .replace(/\n/g, '
') + ); + } +} diff --git a/services/matrix-contacts-bot/src/config/configuration.ts b/services/matrix-contacts-bot/src/config/configuration.ts new file mode 100644 index 000000000..056f1e0fc --- /dev/null +++ b/services/matrix-contacts-bot/src/config/configuration.ts @@ -0,0 +1,48 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3320', 10), + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN || '', + allowedRooms: (process.env.MATRIX_ALLOWED_ROOMS || '').split(',').filter(Boolean), + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + contacts: { + backendUrl: process.env.CONTACTS_BACKEND_URL || 'http://localhost:3015', + apiPrefix: process.env.CONTACTS_API_PREFIX || '/api/v1', + }, + auth: { + url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', + }, +}); + +export const HELP_MESSAGE = `**Contacts Bot - Kontaktverwaltung** + +**Kontakte anzeigen:** +- \`!kontakte\` - Alle Kontakte anzeigen +- \`!suche [text]\` - Kontakte suchen +- \`!favoriten\` - Favoriten anzeigen +- \`!kontakt [nr]\` - Kontakt-Details + +**Kontakte verwalten:** (Login erforderlich) +- \`!neu Vorname Nachname\` - Neuen Kontakt erstellen +- \`!edit [nr] [feld] [wert]\` - Kontakt bearbeiten +- \`!loeschen [nr]\` - Kontakt loschen +- \`!fav [nr]\` - Favorit umschalten +- \`!archiv [nr]\` - Archivieren umschalten + +**Felder fur !edit:** +- \`email\`, \`phone\`, \`mobile\` +- \`company\`, \`job\`, \`website\` +- \`street\`, \`city\`, \`zip\`, \`country\` +- \`notes\`, \`birthday\` + +**Beispiele:** +\`!neu Max Mustermann\` +\`!edit 1 email max@example.com\` +\`!edit 1 phone +49 123 456789\` + +**Sonstiges:** +- \`!login email passwort\` - Anmelden +- \`!logout\` - Abmelden +- \`!status\` - Bot-Status +- \`!help\` - Diese Hilfe`; diff --git a/services/matrix-contacts-bot/src/contacts/contacts.module.ts b/services/matrix-contacts-bot/src/contacts/contacts.module.ts new file mode 100644 index 000000000..999a7a0a4 --- /dev/null +++ b/services/matrix-contacts-bot/src/contacts/contacts.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ContactsService } from './contacts.service'; + +@Module({ + providers: [ContactsService], + exports: [ContactsService], +}) +export class ContactsModule {} diff --git a/services/matrix-contacts-bot/src/contacts/contacts.service.ts b/services/matrix-contacts-bot/src/contacts/contacts.service.ts new file mode 100644 index 000000000..5dd07d5ce --- /dev/null +++ b/services/matrix-contacts-bot/src/contacts/contacts.service.ts @@ -0,0 +1,252 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface Contact { + id: string; + firstName?: string | null; + lastName?: string | null; + displayName?: string | null; + nickname?: string | null; + email?: string | null; + phone?: string | null; + mobile?: string | null; + street?: string | null; + city?: string | null; + postalCode?: string | null; + country?: string | null; + company?: string | null; + jobTitle?: string | null; + department?: string | null; + website?: string | null; + birthday?: string | null; + notes?: string | null; + photoUrl?: string | null; + isFavorite: boolean; + isArchived: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ContactFilters { + search?: string; + isFavorite?: boolean; + isArchived?: boolean; + limit?: number; + offset?: number; +} + +export interface ContactsResult { + contacts: Contact[]; + total: number; +} + +export interface CreateContactDto { + firstName?: string; + lastName?: string; + displayName?: string; + email?: string; + phone?: string; + mobile?: string; + company?: string; + jobTitle?: string; + website?: string; + notes?: string; +} + +@Injectable() +export class ContactsService { + private readonly logger = new Logger(ContactsService.name); + private readonly backendUrl: string; + private readonly apiPrefix: string; + + constructor(private configService: ConfigService) { + this.backendUrl = + this.configService.get('contacts.backendUrl') || 'http://localhost:3015'; + this.apiPrefix = this.configService.get('contacts.apiPrefix') || '/api/v1'; + } + + private getApiUrl(path: string): string { + return `${this.backendUrl}${this.apiPrefix}${path}`; + } + + async checkHealth(): Promise { + try { + const response = await fetch(`${this.backendUrl}/health`); + return response.ok; + } catch (error) { + this.logger.error('Health check failed:', error); + return false; + } + } + + async getContacts(token: string, filters: ContactFilters = {}): Promise { + try { + const params = new URLSearchParams(); + if (filters.search) params.set('search', filters.search); + if (filters.isFavorite !== undefined) params.set('isFavorite', String(filters.isFavorite)); + if (filters.isArchived !== undefined) params.set('isArchived', String(filters.isArchived)); + if (filters.limit) params.set('limit', String(filters.limit)); + if (filters.offset) params.set('offset', String(filters.offset)); + + const url = `${this.getApiUrl('/contacts')}?${params.toString()}`; + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch contacts: ${response.status}`); + } + + return await response.json(); + } catch (error) { + this.logger.error('Failed to fetch contacts:', error); + throw error; + } + } + + async getContact(token: string, contactId: string): Promise { + try { + const response = await fetch(this.getApiUrl(`/contacts/${contactId}`), { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Kontakt nicht gefunden'); + } + throw new Error(`Failed to fetch contact: ${response.status}`); + } + + const data = await response.json(); + return data.contact; + } catch (error) { + this.logger.error(`Failed to fetch contact ${contactId}:`, error); + throw error; + } + } + + async createContact(token: string, data: CreateContactDto): Promise { + try { + const response = await fetch(this.getApiUrl('/contacts'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to create contact: ${response.status}`); + } + + const result = await response.json(); + return result.contact; + } catch (error) { + this.logger.error('Failed to create contact:', error); + throw error; + } + } + + async updateContact(token: string, contactId: string, data: Partial): Promise { + try { + const response = await fetch(this.getApiUrl(`/contacts/${contactId}`), { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Kontakt nicht gefunden'); + } + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to update contact: ${response.status}`); + } + + const result = await response.json(); + return result.contact; + } catch (error) { + this.logger.error(`Failed to update contact ${contactId}:`, error); + throw error; + } + } + + async deleteContact(token: string, contactId: string): Promise { + try { + const response = await fetch(this.getApiUrl(`/contacts/${contactId}`), { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Kontakt nicht gefunden'); + } + throw new Error(`Failed to delete contact: ${response.status}`); + } + } catch (error) { + this.logger.error(`Failed to delete contact ${contactId}:`, error); + throw error; + } + } + + async toggleFavorite(token: string, contactId: string): Promise { + try { + const response = await fetch(this.getApiUrl(`/contacts/${contactId}/favorite`), { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Kontakt nicht gefunden'); + } + throw new Error(`Failed to toggle favorite: ${response.status}`); + } + + const result = await response.json(); + return result.contact; + } catch (error) { + this.logger.error(`Failed to toggle favorite for ${contactId}:`, error); + throw error; + } + } + + async toggleArchive(token: string, contactId: string): Promise { + try { + const response = await fetch(this.getApiUrl(`/contacts/${contactId}/archive`), { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Kontakt nicht gefunden'); + } + throw new Error(`Failed to toggle archive: ${response.status}`); + } + + const result = await response.json(); + return result.contact; + } catch (error) { + this.logger.error(`Failed to toggle archive for ${contactId}:`, error); + throw error; + } + } +} diff --git a/services/matrix-contacts-bot/src/health.controller.ts b/services/matrix-contacts-bot/src/health.controller.ts new file mode 100644 index 000000000..35b68cfd4 --- /dev/null +++ b/services/matrix-contacts-bot/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'matrix-contacts-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/matrix-contacts-bot/src/main.ts b/services/matrix-contacts-bot/src/main.ts new file mode 100644 index 000000000..a19e96313 --- /dev/null +++ b/services/matrix-contacts-bot/src/main.ts @@ -0,0 +1,17 @@ +import { NestFactory } from '@nestjs/core'; +import { Logger } from '@nestjs/common'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + + const app = await NestFactory.create(AppModule); + + const port = process.env.PORT || 3320; + await app.listen(port); + + logger.log(`Matrix Contacts Bot running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} + +bootstrap(); diff --git a/services/matrix-contacts-bot/src/session/session.module.ts b/services/matrix-contacts-bot/src/session/session.module.ts new file mode 100644 index 000000000..834b715eb --- /dev/null +++ b/services/matrix-contacts-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-contacts-bot/src/session/session.service.ts b/services/matrix-contacts-bot/src/session/session.service.ts new file mode 100644 index 000000000..f1bed7852 --- /dev/null +++ b/services/matrix-contacts-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-contacts-bot/tsconfig.json b/services/matrix-contacts-bot/tsconfig.json new file mode 100644 index 000000000..38c2b55d7 --- /dev/null +++ b/services/matrix-contacts-bot/tsconfig.json @@ -0,0 +1,23 @@ +{ + "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": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true + } +}