diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 3178c2ed9..ee3f1f520 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -835,6 +835,82 @@ services: retries: 3 start_period: 40s + # ============================================ + # Matrix Stats Bot (GDPR-compliant Analytics) + # ============================================ + + matrix-stats-bot: + image: ghcr.io/memo-2023/matrix-stats-bot:latest + container_name: manacore-matrix-stats-bot + restart: always + depends_on: + synapse: + condition: service_healthy + umami: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3312 + TZ: Europe/Berlin + MATRIX_HOMESERVER_URL: http://synapse:8008 + MATRIX_ACCESS_TOKEN: ${MATRIX_STATS_BOT_TOKEN} + MATRIX_REPORT_ROOM_ID: ${MATRIX_STATS_REPORT_ROOM:-} + UMAMI_API_URL: http://umami:3000 + UMAMI_USERNAME: ${UMAMI_USERNAME:-admin} + UMAMI_PASSWORD: ${UMAMI_PASSWORD} + DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-manacore123}@postgres:5432/manacore_auth + volumes: + - matrix_stats_bot_data:/app/data + ports: + - "3312:3312" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3312/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # ============================================ + # Matrix Project Doc Bot (GDPR-compliant Documentation) + # ============================================ + + matrix-project-doc-bot: + image: ghcr.io/memo-2023/matrix-project-doc-bot:latest + container_name: manacore-matrix-project-doc-bot + restart: always + depends_on: + synapse: + condition: service_healthy + postgres: + condition: service_healthy + minio: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3313 + TZ: Europe/Berlin + MATRIX_HOMESERVER_URL: http://synapse:8008 + MATRIX_ACCESS_TOKEN: ${MATRIX_PROJECT_DOC_BOT_TOKEN} + MATRIX_ALLOWED_USERS: ${MATRIX_PROJECT_DOC_ALLOWED_USERS:-} + DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-manacore123}@postgres:5432/project_doc_bot + S3_ENDPOINT: http://minio:9000 + S3_REGION: us-east-1 + S3_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} + S3_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} + S3_BUCKET: project-doc-bot + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_MODEL: gpt-4o-mini + volumes: + - matrix_project_doc_bot_data:/app/data + ports: + - "3313:3313" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3313/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + # ============================================ # Auto-Update (Watchtower) # ============================================ @@ -877,3 +953,7 @@ volumes: name: manacore-synapse matrix_ollama_bot_data: name: manacore-matrix-ollama-bot + matrix_stats_bot_data: + name: manacore-matrix-stats-bot + matrix_project_doc_bot_data: + name: manacore-matrix-project-doc-bot diff --git a/scripts/mac-mini/health-check.sh b/scripts/mac-mini/health-check.sh index f121f150e..c8f6566ab 100755 --- a/scripts/mac-mini/health-check.sh +++ b/scripts/mac-mini/health-check.sh @@ -234,6 +234,8 @@ echo "Matrix (DSGVO-konform):" check_service "Synapse" "http://localhost:8008/health" check_service "Element Web" "http://localhost:8087/" check_service "Matrix Ollama Bot" "http://localhost:3311/health" +check_service "Matrix Stats Bot" "http://localhost:3312/health" +check_service "Matrix Project Doc Bot" "http://localhost:3313/health" echo "" echo "Cloudflare Tunnel:" diff --git a/services/matrix-project-doc-bot/.env.example b/services/matrix-project-doc-bot/.env.example new file mode 100644 index 000000000..8045521f6 --- /dev/null +++ b/services/matrix-project-doc-bot/.env.example @@ -0,0 +1,23 @@ +PORT=3313 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +# Optional: Restrict to specific users (comma-separated) +MATRIX_ALLOWED_USERS= +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Database +DATABASE_URL=postgresql://postgres:password@localhost:5432/project_doc_bot + +# S3 Storage +S3_ENDPOINT=http://localhost:9000 +S3_REGION=us-east-1 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=project-doc-bot + +# OpenAI +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o-mini +OPENAI_WHISPER_MODEL=whisper-1 diff --git a/services/matrix-project-doc-bot/CLAUDE.md b/services/matrix-project-doc-bot/CLAUDE.md new file mode 100644 index 000000000..a0d8b0415 --- /dev/null +++ b/services/matrix-project-doc-bot/CLAUDE.md @@ -0,0 +1,122 @@ +# Matrix Project Doc Bot - Claude Code Guidelines + +## Overview + +Matrix Project Doc Bot collects photos, voice notes, and text for projects and generates blog posts. GDPR-compliant replacement for telegram-project-doc-bot. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Database**: Drizzle ORM + PostgreSQL +- **Storage**: S3 (MinIO locally, Hetzner in production) +- **AI**: OpenAI (Whisper for transcription, GPT-4o-mini for generation) + +## Commands + +```bash +pnpm install +pnpm start:dev # Development with hot reload +pnpm build # Production build +pnpm type-check # TypeScript check +pnpm db:push # Push schema to database +pnpm db:studio # Open Drizzle Studio +``` + +## Matrix Commands + +| Command | Description | +|---------|-------------| +| `!new [Name]` | Create new project | +| `!projects` | List all projects | +| `!switch [ID]` | Switch to project | +| `!status` | Show project status | +| `!archive` | Archive current project | +| `!generate` | Generate blog post (casual) | +| `!generate [style]` | Generate with specific style | +| `!styles` | Show available styles | +| `!export` | Export last generation | + +## Media Handling + +- **Photos**: Saved to S3, stored in database +- **Voice**: Saved to S3, transcribed via Whisper +- **Text**: Stored directly in database + +## Blog Styles + +| Style | Description | +|-------|-------------| +| `casual` | Friendly, personal blog post | +| `technical` | Detailed technical report | +| `tutorial` | Step-by-step guide | +| `social` | Short social media post | +| `story` | Storytelling format | + +## Environment Variables + +```env +PORT=3313 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_USERS=@user:mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Database +DATABASE_URL=postgresql://postgres:password@localhost:5432/project_doc_bot + +# S3 Storage +S3_ENDPOINT=http://localhost:9000 +S3_REGION=us-east-1 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=project-doc-bot + +# OpenAI +OPENAI_API_KEY=sk-xxx +OPENAI_MODEL=gpt-4o-mini +OPENAI_WHISPER_MODEL=whisper-1 +``` + +## Database Schema + +```sql +-- projects table +CREATE TABLE projects ( + id UUID PRIMARY KEY, + matrix_user_id TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- project_items table +CREATE TABLE project_items ( + id UUID PRIMARY KEY, + project_id UUID REFERENCES projects(id), + type TEXT NOT NULL, -- photo, voice, text + content TEXT, + media_url TEXT, + media_mxc_url TEXT, + duration INTEGER, + created_at TIMESTAMP DEFAULT NOW() +); + +-- generations table +CREATE TABLE generations ( + id UUID PRIMARY KEY, + project_id UUID REFERENCES projects(id), + style TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +## Health Check + +```bash +curl http://localhost:3313/health +``` diff --git a/services/matrix-project-doc-bot/Dockerfile b/services/matrix-project-doc-bot/Dockerfile new file mode 100644 index 000000000..0cba86843 --- /dev/null +++ b/services/matrix-project-doc-bot/Dockerfile @@ -0,0 +1,25 @@ +FROM node:20-alpine AS builder +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile || pnpm install +COPY . . +RUN pnpm build + +FROM node:20-alpine AS runner +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +RUN mkdir -p /app/data +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --prod --frozen-lockfile || pnpm install --prod +COPY --from=builder /app/dist ./dist +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nestjs +RUN chown -R nestjs:nodejs /app +USER nestjs + +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3313/health || exit 1 + +EXPOSE 3313 +CMD ["node", "dist/main.js"] diff --git a/services/matrix-project-doc-bot/drizzle.config.ts b/services/matrix-project-doc-bot/drizzle.config.ts new file mode 100644 index 000000000..695ea628c --- /dev/null +++ b/services/matrix-project-doc-bot/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/database/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL || '', + }, +}); diff --git a/services/matrix-project-doc-bot/nest-cli.json b/services/matrix-project-doc-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/matrix-project-doc-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-project-doc-bot/package.json b/services/matrix-project-doc-bot/package.json new file mode 100644 index 000000000..6d10f5630 --- /dev/null +++ b/services/matrix-project-doc-bot/package.json @@ -0,0 +1,43 @@ +{ + "name": "@manacore/matrix-project-doc-bot", + "version": "1.0.0", + "description": "Matrix bot for project documentation - collect photos and voice notes, generate blog posts (GDPR compliant)", + "private": true, + "license": "MIT", + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "@aws-sdk/client-s3": "^3.721.0", + "@aws-sdk/s3-request-presigner": "^3.721.0", + "drizzle-orm": "^0.38.3", + "matrix-bot-sdk": "^0.7.1", + "openai": "^4.77.0", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "drizzle-kit": "^0.30.1", + "rimraf": "^6.0.1", + "typescript": "^5.7.3" + } +} diff --git a/services/matrix-project-doc-bot/src/app.module.ts b/services/matrix-project-doc-bot/src/app.module.ts new file mode 100644 index 000000000..9eabccba9 --- /dev/null +++ b/services/matrix-project-doc-bot/src/app.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { DatabaseModule } from './database/database.module'; +import { BotModule } from './bot/bot.module'; +import { HealthController } from './health.controller'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + DatabaseModule, + BotModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-project-doc-bot/src/bot/bot.module.ts b/services/matrix-project-doc-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..506cb7fa3 --- /dev/null +++ b/services/matrix-project-doc-bot/src/bot/bot.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { ProjectModule } from '../project/project.module'; +import { MediaModule } from '../media/media.module'; +import { GenerationModule } from '../generation/generation.module'; + +@Module({ + imports: [ProjectModule, MediaModule, GenerationModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-project-doc-bot/src/bot/matrix.service.ts b/services/matrix-project-doc-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..60e4ec0ac --- /dev/null +++ b/services/matrix-project-doc-bot/src/bot/matrix.service.ts @@ -0,0 +1,442 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + AutojoinRoomsMixin, + RichConsoleLogger, + LogService, + MessageEvent, + RoomEvent, +} from 'matrix-bot-sdk'; +import { ProjectService } from '../project/project.service'; +import { MediaService } from '../media/media.service'; +import { GenerationService } from '../generation/generation.service'; +import { BLOG_STYLES } from '../config/configuration'; + +@Injectable() +export class MatrixService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MatrixService.name); + private client!: MatrixClient; + private botUserId: string = ''; + private readonly allowedUsers: string[]; + + // Active project per user (matrixUserId -> projectId) + private activeProjects: Map = new Map(); + + constructor( + private configService: ConfigService, + private projectService: ProjectService, + private mediaService: MediaService, + private generationService: GenerationService + ) { + this.allowedUsers = this.configService.get('matrix.allowedUsers') || []; + } + + 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(LogService.LogLevel.INFO); + + const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); + this.client = new MatrixClient(homeserverUrl!, accessToken, storage); + + AutojoinRoomsMixin.setupOnClient(this.client); + + 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 Project Doc Bot started successfully'); + } + + async onModuleDestroy() { + if (this.client) { + await this.client.stop(); + } + } + + private isAllowed(userId: string): boolean { + if (this.allowedUsers.length === 0) return true; + return this.allowedUsers.includes(userId); + } + + private async handleRoomMessage(roomId: string, event: RoomEvent) { + if (event.sender === this.botUserId) return; + if (!this.isAllowed(event.sender)) return; + + const content = event.content; + const msgtype = content.msgtype; + + if (msgtype === 'm.text') { + const body = content.body; + if (body.startsWith('!')) { + await this.handleCommand(roomId, event.sender, body); + } else { + await this.handleTextMessage(roomId, event.sender, body); + } + } else if (msgtype === 'm.image') { + await this.handleImage(roomId, event.sender, content); + } else if (msgtype === 'm.audio') { + await this.handleAudio(roomId, event.sender, content); + } + } + + private async handleCommand(roomId: string, sender: string, body: string) { + const [command, ...args] = body.slice(1).split(' '); + const argString = args.join(' '); + + switch (command.toLowerCase()) { + case 'help': + case 'start': + await this.sendHelp(roomId); + break; + case 'new': + await this.createProject(roomId, sender, argString); + break; + case 'projects': + await this.listProjects(roomId, sender); + break; + case 'switch': + await this.switchProject(roomId, sender, argString); + break; + case 'status': + await this.showStatus(roomId, sender); + break; + case 'archive': + await this.archiveProject(roomId, sender); + break; + case 'styles': + await this.showStyles(roomId); + break; + case 'generate': + await this.generateBlogpost(roomId, sender, argString); + break; + case 'export': + await this.exportGeneration(roomId, sender); + break; + default: + await this.sendMessage(roomId, `Unbekannter Befehl: !${command}\n\nVerwende !help`); + } + } + + private async sendHelp(roomId: string) { + const styles = Object.entries(BLOG_STYLES) + .map(([key, value]) => `- \`${key}\` - ${value.name}`) + .join('\n'); + + const helpText = `**📸 Project Doc Bot (DSGVO-konform)** + +Sammle Fotos, Sprachnotizen und Text für deine Projekte und erstelle daraus Blogbeiträge. + +**Projekt-Commands:** +- \`!new [Name]\` - Neues Projekt starten +- \`!projects\` - Alle Projekte anzeigen +- \`!switch [ID]\` - Projekt wechseln +- \`!status\` - Status des aktiven Projekts +- \`!archive\` - Aktives Projekt archivieren + +**Content:** +📷 Foto senden - Wird gespeichert +🎤 Sprachnotiz - Wird transkribiert +💬 Text-Nachricht - Als Notiz gespeichert + +**Generierung:** +- \`!generate\` - Blogbeitrag erstellen +- \`!generate [Stil]\` - Mit bestimmtem Stil +- \`!styles\` - Verfügbare Stile anzeigen +- \`!export\` - Letzte Generierung exportieren + +**Verfügbare Stile:** +${styles} + +**Tipp:** Starte mit \`!new Projektname\``; + + await this.sendMessage(roomId, helpText); + } + + private async createProject(roomId: string, sender: string, name: string) { + if (!name) { + await this.sendMessage(roomId, 'Verwendung: `!new Projektname`\n\nBeispiel: `!new Gartenhaus-Renovierung`'); + return; + } + + try { + const project = await this.projectService.create({ + matrixUserId: sender, + name, + }); + + this.activeProjects.set(sender, project.id); + + await this.sendMessage( + roomId, + `✅ **Projekt erstellt!**\n\n**Name:** ${project.name}\n**ID:** \`${project.id.slice(0, 8)}\`\n\nSende jetzt:\n📷 Fotos\n🎤 Sprachnotizen\n💬 Text-Nachrichten\n\nMit \`!generate\` erstellst du den Blogbeitrag.` + ); + } catch (error) { + this.logger.error('Failed to create project:', error); + await this.sendMessage(roomId, `❌ Fehler: ${error instanceof Error ? error.message : 'Unbekannt'}`); + } + } + + private async listProjects(roomId: string, sender: string) { + const projects = await this.projectService.findByUser(sender); + + if (projects.length === 0) { + await this.sendMessage(roomId, 'Keine Projekte gefunden.\n\nStarte mit: `!new Projektname`'); + return; + } + + const activeId = this.activeProjects.get(sender); + + const projectList = await Promise.all( + projects.map(async (p) => { + const stats = await this.projectService.getStats(p.id); + const active = p.id === activeId ? ' ✓' : ''; + const status = p.status === 'archived' ? ' 📦' : ''; + return `- **${p.name}**${active}${status}\n ID: \`${p.id.slice(0, 8)}\` | ${stats.total} Einträge`; + }) + ); + + await this.sendMessage(roomId, `**📂 Deine Projekte:**\n\n${projectList.join('\n\n')}\n\nWechseln mit: \`!switch [ID]\``); + } + + private async switchProject(roomId: string, sender: string, idPrefix: string) { + if (!idPrefix) { + await this.sendMessage(roomId, 'Verwendung: `!switch [ID]`\n\nZeige Projekte mit `!projects`'); + return; + } + + const projects = await this.projectService.findByUser(sender); + const project = projects.find((p) => p.id.startsWith(idPrefix)); + + if (!project) { + await this.sendMessage(roomId, `Projekt mit ID "${idPrefix}" nicht gefunden.`); + return; + } + + this.activeProjects.set(sender, project.id); + const stats = await this.projectService.getStats(project.id); + + await this.sendMessage( + roomId, + `✅ Gewechselt zu: **${project.name}**\n\n📷 ${stats.photos} Fotos\n🎤 ${stats.voices} Sprachnotizen\n📝 ${stats.texts} Textnotizen` + ); + } + + private async showStatus(roomId: string, sender: string) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, 'Kein aktives Projekt.\n\nStarte mit: `!new Projektname`'); + return; + } + + const project = await this.projectService.findById(projectId); + if (!project) { + this.activeProjects.delete(sender); + await this.sendMessage(roomId, 'Projekt nicht gefunden. Starte ein neues mit `!new`'); + return; + } + + const stats = await this.projectService.getStats(projectId); + const latest = await this.generationService.getLatestGeneration(projectId); + + let statusText = `**📊 Projekt-Status**\n\n**Name:** ${project.name}\n**Status:** ${project.status}\n**Erstellt:** ${project.createdAt.toLocaleDateString('de-DE')}\n\n**Inhalte:**\n📷 ${stats.photos} Fotos\n🎤 ${stats.voices} Sprachnotizen\n📝 ${stats.texts} Textnotizen\n**Gesamt:** ${stats.total} Einträge`; + + if (latest) { + statusText += `\n\n**Letzte Generierung:**\n${latest.createdAt.toLocaleString('de-DE')} (${latest.style})`; + } + + await this.sendMessage(roomId, statusText); + } + + private async archiveProject(roomId: string, sender: string) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, 'Kein aktives Projekt.'); + return; + } + + await this.projectService.update(projectId, { status: 'archived' }); + this.activeProjects.delete(sender); + + await this.sendMessage(roomId, '📦 Projekt archiviert.\n\nStarte ein neues mit `!new`'); + } + + private async showStyles(roomId: string) { + const styles = Object.entries(BLOG_STYLES) + .map(([key, value]) => `**${key}** - ${value.name}\n_${value.prompt.slice(0, 80)}..._`) + .join('\n\n'); + + await this.sendMessage(roomId, `**📝 Verfügbare Blog-Stile:**\n\n${styles}\n\nVerwendung: \`!generate [stil]\``); + } + + private async generateBlogpost(roomId: string, sender: string, style: string) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, 'Kein aktives Projekt.\n\nStarte mit: `!new Projektname`'); + return; + } + + const selectedStyle = (style.toLowerCase() || 'casual') as keyof typeof BLOG_STYLES; + const validStyles = Object.keys(BLOG_STYLES); + + if (!validStyles.includes(selectedStyle)) { + await this.sendMessage( + roomId, + `Unbekannter Stil: "${style}"\n\nVerfügbar: ${validStyles.join(', ')}\n\nZeige Details mit \`!styles\`` + ); + return; + } + + await this.sendMessage(roomId, '🚀 Generiere Blogbeitrag...\n\nDas kann einen Moment dauern.'); + await this.client.sendTyping(roomId, true, 60000); + + try { + const content = await this.generationService.generateBlogpost(projectId, selectedStyle); + await this.client.sendTyping(roomId, false); + + await this.sendMessage(roomId, content); + await this.sendMessage(roomId, '✅ Blogbeitrag erstellt!\n\nExportieren mit `!export`'); + } catch (error) { + await this.client.sendTyping(roomId, false); + this.logger.error('Generation failed:', error); + await this.sendMessage(roomId, `❌ Fehler: ${error instanceof Error ? error.message : 'Unbekannt'}`); + } + } + + private async exportGeneration(roomId: string, sender: string) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, 'Kein aktives Projekt.'); + return; + } + + const latest = await this.generationService.getLatestGeneration(projectId); + if (!latest) { + await this.sendMessage(roomId, 'Noch kein Blogbeitrag generiert.\n\nErstelle einen mit `!generate`'); + return; + } + + const project = await this.projectService.findById(projectId); + const filename = `${project?.name.replace(/[^a-zA-Z0-9]/g, '_') || 'blogpost'}.md`; + + // Upload file to Matrix + const buffer = Buffer.from(latest.content, 'utf-8'); + const mxcUrl = await this.client.uploadContent(buffer, 'text/markdown', filename); + + await this.client.sendMessage(roomId, { + msgtype: 'm.file', + body: filename, + url: mxcUrl, + info: { + mimetype: 'text/markdown', + size: buffer.length, + }, + }); + } + + private async handleTextMessage(roomId: string, sender: string, text: string) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, '💡 Tipp: Starte ein Projekt mit `!new Projektname`'); + return; + } + + try { + await this.mediaService.addTextNote(projectId, text); + const stats = await this.projectService.getStats(projectId); + await this.sendMessage(roomId, `📝 Notiz gespeichert! (${stats.texts} Notizen gesamt)`); + } catch (error) { + this.logger.error('Failed to add text note:', error); + await this.sendMessage(roomId, '❌ Fehler beim Speichern der Notiz.'); + } + } + + private async handleImage(roomId: string, sender: string, content: any) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, 'Kein aktives Projekt.\n\nStarte mit: `!new Projektname`'); + return; + } + + try { + const mxcUrl = content.url; + const httpUrl = this.client.mxcToHttp(mxcUrl); + const response = await fetch(httpUrl); + const buffer = Buffer.from(await response.arrayBuffer()); + const contentType = content.info?.mimetype || 'image/jpeg'; + + await this.mediaService.processPhoto(projectId, buffer, contentType, mxcUrl, content.body); + + const stats = await this.projectService.getStats(projectId); + await this.sendMessage(roomId, `📷 Foto gespeichert! (${stats.photos} Fotos gesamt)`); + } catch (error) { + this.logger.error('Failed to process image:', error); + await this.sendMessage(roomId, '❌ Fehler beim Speichern des Fotos.'); + } + } + + private async handleAudio(roomId: string, sender: string, content: any) { + const projectId = this.activeProjects.get(sender); + if (!projectId) { + await this.sendMessage(roomId, 'Kein aktives Projekt.\n\nStarte mit: `!new Projektname`'); + return; + } + + await this.sendMessage(roomId, '🎤 Verarbeite Sprachnotiz...'); + + try { + const mxcUrl = content.url; + const httpUrl = this.client.mxcToHttp(mxcUrl); + const response = await fetch(httpUrl); + const buffer = Buffer.from(await response.arrayBuffer()); + const contentType = content.info?.mimetype || 'audio/ogg'; + const duration = Math.round((content.info?.duration || 0) / 1000); + + const item = await this.mediaService.processVoice(projectId, buffer, contentType, mxcUrl, duration); + + const stats = await this.projectService.getStats(projectId); + let reply = `✅ Sprachnotiz gespeichert! (${stats.voices} gesamt)`; + + if (item.content) { + reply += `\n\n📝 Transkription:\n"${item.content}"`; + } + + await this.sendMessage(roomId, reply); + } catch (error) { + this.logger.error('Failed to process audio:', error); + await this.sendMessage(roomId, '❌ Fehler beim Verarbeiten der Sprachnotiz.'); + } + } + + 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(/\n/g, '
'); + } +} diff --git a/services/matrix-project-doc-bot/src/config/configuration.ts b/services/matrix-project-doc-bot/src/config/configuration.ts new file mode 100644 index 000000000..4bc5aa810 --- /dev/null +++ b/services/matrix-project-doc-bot/src/config/configuration.ts @@ -0,0 +1,47 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3313', 10), + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN || '', + allowedUsers: process.env.MATRIX_ALLOWED_USERS?.split(',').filter(Boolean) || [], + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + database: { + url: process.env.DATABASE_URL || '', + }, + s3: { + endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000', + region: process.env.S3_REGION || 'us-east-1', + accessKey: process.env.S3_ACCESS_KEY || 'minioadmin', + secretKey: process.env.S3_SECRET_KEY || 'minioadmin', + bucket: process.env.S3_BUCKET || 'project-doc-bot', + }, + openai: { + apiKey: process.env.OPENAI_API_KEY || '', + model: process.env.OPENAI_MODEL || 'gpt-4o-mini', + whisperModel: process.env.OPENAI_WHISPER_MODEL || 'whisper-1', + }, +}); + +export const BLOG_STYLES: Record = { + casual: { + name: 'Casual Blog', + prompt: `Schreibe einen lockeren, persönlichen Blogbeitrag über dieses Projekt. Nutze eine freundliche, nahbare Sprache. Füge passende Überschriften und Absätze ein.`, + }, + technical: { + name: 'Technischer Bericht', + prompt: `Schreibe einen detaillierten technischen Bericht über dieses Projekt. Fokussiere auf Methoden, Materialien und den Prozess. Sei präzise und informativ.`, + }, + tutorial: { + name: 'Schritt-für-Schritt Anleitung', + prompt: `Erstelle eine Schritt-für-Schritt Anleitung basierend auf diesem Projekt. Nummeriere die Schritte und erkläre jeden ausführlich, sodass andere es nachmachen können.`, + }, + social: { + name: 'Social Media Post', + prompt: `Erstelle einen kurzen, ansprechenden Social Media Post über dieses Projekt. Maximal 280 Zeichen für den Haupttext, plus optionale Hashtags.`, + }, + story: { + name: 'Storytelling', + prompt: `Erzähle die Geschichte dieses Projekts. Beginne mit der Motivation, beschreibe Herausforderungen und ende mit dem Ergebnis. Mach es persönlich und fesselnd.`, + }, +}; diff --git a/services/matrix-project-doc-bot/src/database/database.module.ts b/services/matrix-project-doc-bot/src/database/database.module.ts new file mode 100644 index 000000000..9acacdc60 --- /dev/null +++ b/services/matrix-project-doc-bot/src/database/database.module.ts @@ -0,0 +1,33 @@ +import { Module, Global, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; + +@Global() +@Module({ + providers: [ + { + provide: DATABASE_CONNECTION, + useFactory: (configService: ConfigService) => { + const logger = new Logger('Database'); + const url = configService.get('database.url'); + + if (!url) { + logger.error('DATABASE_URL is required'); + throw new Error('DATABASE_URL is required'); + } + + const client = postgres(url); + logger.log('Database connected'); + + return drizzle(client, { schema }); + }, + inject: [ConfigService], + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule {} diff --git a/services/matrix-project-doc-bot/src/database/schema.ts b/services/matrix-project-doc-bot/src/database/schema.ts new file mode 100644 index 000000000..eb8041de7 --- /dev/null +++ b/services/matrix-project-doc-bot/src/database/schema.ts @@ -0,0 +1,33 @@ +import { pgTable, text, timestamp, uuid, integer } from 'drizzle-orm/pg-core'; + +export const projects = pgTable('projects', { + id: uuid('id').primaryKey().defaultRandom(), + matrixUserId: text('matrix_user_id').notNull(), + name: text('name').notNull(), + status: text('status').notNull().default('active'), // active, archived + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), +}); + +export const projectItems = pgTable('project_items', { + id: uuid('id').primaryKey().defaultRandom(), + projectId: uuid('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + type: text('type').notNull(), // photo, voice, text + content: text('content'), // text content or transcription + mediaUrl: text('media_url'), // S3 URL for media + mediaMxcUrl: text('media_mxc_url'), // Matrix MXC URL + duration: integer('duration'), // Voice duration in seconds + createdAt: timestamp('created_at').notNull().defaultNow(), +}); + +export const generations = pgTable('generations', { + id: uuid('id').primaryKey().defaultRandom(), + projectId: uuid('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + style: text('style').notNull(), + content: text('content').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), +}); diff --git a/services/matrix-project-doc-bot/src/generation/generation.module.ts b/services/matrix-project-doc-bot/src/generation/generation.module.ts new file mode 100644 index 000000000..fccb8adfd --- /dev/null +++ b/services/matrix-project-doc-bot/src/generation/generation.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { GenerationService } from './generation.service'; + +@Module({ + providers: [GenerationService], + exports: [GenerationService], +}) +export class GenerationModule {} diff --git a/services/matrix-project-doc-bot/src/generation/generation.service.ts b/services/matrix-project-doc-bot/src/generation/generation.service.ts new file mode 100644 index 000000000..9b00d2585 --- /dev/null +++ b/services/matrix-project-doc-bot/src/generation/generation.service.ts @@ -0,0 +1,112 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import OpenAI from 'openai'; +import { eq, desc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../database/database.module'; +import { generations, projectItems, projects } from '../database/schema'; +import { BLOG_STYLES } from '../config/configuration'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import type * as schema from '../database/schema'; + +type Database = PostgresJsDatabase; + +@Injectable() +export class GenerationService { + private readonly logger = new Logger(GenerationService.name); + private readonly openai: OpenAI; + private readonly model: string; + + constructor( + @Inject(DATABASE_CONNECTION) private db: Database, + private configService: ConfigService + ) { + this.openai = new OpenAI({ + apiKey: this.configService.get('openai.apiKey'), + }); + this.model = this.configService.get('openai.model') || 'gpt-4o-mini'; + } + + async generateBlogpost(projectId: string, style: keyof typeof BLOG_STYLES): Promise { + const apiKey = this.configService.get('openai.apiKey'); + if (!apiKey) { + throw new Error('OpenAI API key not configured'); + } + + // Get project info + const [project] = await this.db.select().from(projects).where(eq(projects.id, projectId)); + if (!project) { + throw new Error('Project not found'); + } + + // Get all project items + const items = await this.db + .select() + .from(projectItems) + .where(eq(projectItems.projectId, projectId)) + .orderBy(projectItems.createdAt); + + if (items.length === 0) { + throw new Error('Keine Inhalte im Projekt. Füge zuerst Fotos, Sprachnotizen oder Text hinzu.'); + } + + // Build content summary + const contentSummary = items + .map((item, index) => { + const timestamp = item.createdAt.toLocaleString('de-DE'); + switch (item.type) { + case 'photo': + return `[Foto ${index + 1}] ${timestamp}${item.content ? `: ${item.content}` : ''}`; + case 'voice': + return `[Sprachnotiz ${index + 1}] ${timestamp}: "${item.content || 'Keine Transkription'}"`; + case 'text': + return `[Notiz ${index + 1}] ${timestamp}: "${item.content}"`; + default: + return ''; + } + }) + .filter(Boolean) + .join('\n\n'); + + const styleConfig = BLOG_STYLES[style]; + + const systemPrompt = `Du bist ein erfahrener Blogger und Content-Creator. ${styleConfig.prompt} + +Projektname: "${project.name}" +Erstellt am: ${project.createdAt.toLocaleDateString('de-DE')} + +Die folgenden Inhalte wurden während des Projekts gesammelt:`; + + const response = await this.openai.chat.completions.create({ + model: this.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: contentSummary }, + ], + temperature: 0.7, + max_tokens: 2000, + }); + + const content = response.choices[0]?.message?.content || ''; + + // Save generation + await this.db.insert(generations).values({ + projectId, + style, + content, + }); + + this.logger.log(`Generated ${style} blogpost for project ${projectId}`); + return content; + } + + async getLatestGeneration(projectId: string) { + const [generation] = await this.db + .select() + .from(generations) + .where(eq(generations.projectId, projectId)) + .orderBy(desc(generations.createdAt)) + .limit(1); + + return generation; + } +} diff --git a/services/matrix-project-doc-bot/src/health.controller.ts b/services/matrix-project-doc-bot/src/health.controller.ts new file mode 100644 index 000000000..869b95e8b --- /dev/null +++ b/services/matrix-project-doc-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-project-doc-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/matrix-project-doc-bot/src/main.ts b/services/matrix-project-doc-bot/src/main.ts new file mode 100644 index 000000000..8e0c8c844 --- /dev/null +++ b/services/matrix-project-doc-bot/src/main.ts @@ -0,0 +1,15 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + const port = process.env.PORT || 3313; + await app.listen(port); + + logger.log(`Matrix Project Doc Bot running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} +bootstrap(); diff --git a/services/matrix-project-doc-bot/src/media/media.module.ts b/services/matrix-project-doc-bot/src/media/media.module.ts new file mode 100644 index 000000000..7d62a4e77 --- /dev/null +++ b/services/matrix-project-doc-bot/src/media/media.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MediaService } from './media.service'; +import { StorageService } from './storage.service'; +import { TranscriptionModule } from '../transcription/transcription.module'; + +@Module({ + imports: [TranscriptionModule], + providers: [MediaService, StorageService], + exports: [MediaService, StorageService], +}) +export class MediaModule {} diff --git a/services/matrix-project-doc-bot/src/media/media.service.ts b/services/matrix-project-doc-bot/src/media/media.service.ts new file mode 100644 index 000000000..d545b2d5c --- /dev/null +++ b/services/matrix-project-doc-bot/src/media/media.service.ts @@ -0,0 +1,92 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { DATABASE_CONNECTION } from '../database/database.module'; +import { projectItems } from '../database/schema'; +import { StorageService } from './storage.service'; +import { TranscriptionService } from '../transcription/transcription.service'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import type * as schema from '../database/schema'; + +type Database = PostgresJsDatabase; + +@Injectable() +export class MediaService { + private readonly logger = new Logger(MediaService.name); + + constructor( + @Inject(DATABASE_CONNECTION) private db: Database, + private storageService: StorageService, + private transcriptionService: TranscriptionService + ) {} + + async processPhoto( + projectId: string, + buffer: Buffer, + contentType: string, + mxcUrl: string, + caption?: string + ) { + const key = await this.storageService.uploadFile(buffer, contentType, projectId); + + const [item] = await this.db + .insert(projectItems) + .values({ + projectId, + type: 'photo', + content: caption || null, + mediaUrl: key, + mediaMxcUrl: mxcUrl, + }) + .returning(); + + this.logger.log(`Saved photo for project ${projectId}`); + return item; + } + + async processVoice( + projectId: string, + buffer: Buffer, + contentType: string, + mxcUrl: string, + duration: number + ) { + const key = await this.storageService.uploadFile(buffer, contentType, projectId); + + // Transcribe the voice message + let transcription: string | null = null; + try { + transcription = await this.transcriptionService.transcribe(buffer); + this.logger.log(`Transcribed voice message: ${transcription?.substring(0, 50)}...`); + } catch (error) { + this.logger.error('Transcription failed:', error); + } + + const [item] = await this.db + .insert(projectItems) + .values({ + projectId, + type: 'voice', + content: transcription, + mediaUrl: key, + mediaMxcUrl: mxcUrl, + duration, + }) + .returning(); + + this.logger.log(`Saved voice message for project ${projectId}`); + return item; + } + + async addTextNote(projectId: string, content: string) { + const [item] = await this.db + .insert(projectItems) + .values({ + projectId, + type: 'text', + content, + }) + .returning(); + + this.logger.log(`Saved text note for project ${projectId}`); + return item; + } +} diff --git a/services/matrix-project-doc-bot/src/media/storage.service.ts b/services/matrix-project-doc-bot/src/media/storage.service.ts new file mode 100644 index 000000000..f2d14b672 --- /dev/null +++ b/services/matrix-project-doc-bot/src/media/storage.service.ts @@ -0,0 +1,83 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { randomUUID } from 'crypto'; + +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + private readonly s3Client: S3Client; + private readonly bucket: string; + + constructor(private configService: ConfigService) { + this.s3Client = new S3Client({ + endpoint: this.configService.get('s3.endpoint'), + region: this.configService.get('s3.region'), + credentials: { + accessKeyId: this.configService.get('s3.accessKey') || '', + secretAccessKey: this.configService.get('s3.secretKey') || '', + }, + forcePathStyle: true, + }); + + this.bucket = this.configService.get('s3.bucket') || 'project-doc-bot'; + } + + async uploadFile(buffer: Buffer, contentType: string, projectId: string): Promise { + const extension = this.getExtension(contentType); + const key = `${projectId}/${randomUUID()}${extension}`; + + await this.s3Client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: buffer, + ContentType: contentType, + }) + ); + + this.logger.log(`Uploaded file: ${key}`); + return key; + } + + async getSignedUrl(key: string, expiresIn: number = 3600): Promise { + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + + return getSignedUrl(this.s3Client, command, { expiresIn }); + } + + async downloadFile(key: string): Promise { + const response = await this.s3Client.send( + new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }) + ); + + const stream = response.Body as NodeJS.ReadableStream; + const chunks: Buffer[] = []; + + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + + return Buffer.concat(chunks); + } + + private getExtension(contentType: string): string { + const map: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'audio/ogg': '.ogg', + 'audio/mpeg': '.mp3', + 'audio/mp4': '.m4a', + }; + return map[contentType] || ''; + } +} diff --git a/services/matrix-project-doc-bot/src/project/project.module.ts b/services/matrix-project-doc-bot/src/project/project.module.ts new file mode 100644 index 000000000..c1b3f70d8 --- /dev/null +++ b/services/matrix-project-doc-bot/src/project/project.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ProjectService } from './project.service'; + +@Module({ + providers: [ProjectService], + exports: [ProjectService], +}) +export class ProjectModule {} diff --git a/services/matrix-project-doc-bot/src/project/project.service.ts b/services/matrix-project-doc-bot/src/project/project.service.ts new file mode 100644 index 000000000..2b251d0bc --- /dev/null +++ b/services/matrix-project-doc-bot/src/project/project.service.ts @@ -0,0 +1,74 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { eq, desc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../database/database.module'; +import { projects, projectItems } from '../database/schema'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import type * as schema from '../database/schema'; + +type Database = PostgresJsDatabase; + +interface CreateProjectInput { + matrixUserId: string; + name: string; +} + +@Injectable() +export class ProjectService { + private readonly logger = new Logger(ProjectService.name); + + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async create(input: CreateProjectInput) { + const [project] = await this.db + .insert(projects) + .values({ + matrixUserId: input.matrixUserId, + name: input.name, + }) + .returning(); + + this.logger.log(`Created project ${project.id} for user ${input.matrixUserId}`); + return project; + } + + async findById(id: string) { + const [project] = await this.db.select().from(projects).where(eq(projects.id, id)); + return project; + } + + async findByUser(matrixUserId: string) { + return this.db + .select() + .from(projects) + .where(eq(projects.matrixUserId, matrixUserId)) + .orderBy(desc(projects.createdAt)); + } + + async update(id: string, data: Partial) { + const [project] = await this.db + .update(projects) + .set({ ...data, updatedAt: new Date() }) + .where(eq(projects.id, id)) + .returning(); + return project; + } + + async getStats(projectId: string) { + const items = await this.db.select().from(projectItems).where(eq(projectItems.projectId, projectId)); + + return { + photos: items.filter((i) => i.type === 'photo').length, + voices: items.filter((i) => i.type === 'voice').length, + texts: items.filter((i) => i.type === 'text').length, + total: items.length, + }; + } + + async getItems(projectId: string) { + return this.db + .select() + .from(projectItems) + .where(eq(projectItems.projectId, projectId)) + .orderBy(projectItems.createdAt); + } +} diff --git a/services/matrix-project-doc-bot/src/transcription/transcription.module.ts b/services/matrix-project-doc-bot/src/transcription/transcription.module.ts new file mode 100644 index 000000000..fb5aeeaf1 --- /dev/null +++ b/services/matrix-project-doc-bot/src/transcription/transcription.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TranscriptionService } from './transcription.service'; + +@Module({ + providers: [TranscriptionService], + exports: [TranscriptionService], +}) +export class TranscriptionModule {} diff --git a/services/matrix-project-doc-bot/src/transcription/transcription.service.ts b/services/matrix-project-doc-bot/src/transcription/transcription.service.ts new file mode 100644 index 000000000..6eca0b5d6 --- /dev/null +++ b/services/matrix-project-doc-bot/src/transcription/transcription.service.ts @@ -0,0 +1,40 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import OpenAI from 'openai'; +import { Readable } from 'stream'; + +@Injectable() +export class TranscriptionService { + private readonly logger = new Logger(TranscriptionService.name); + private readonly openai: OpenAI; + private readonly model: string; + + constructor(private configService: ConfigService) { + const apiKey = this.configService.get('openai.apiKey'); + + if (!apiKey) { + this.logger.warn('OPENAI_API_KEY not configured - transcription disabled'); + } + + this.openai = new OpenAI({ apiKey }); + this.model = this.configService.get('openai.whisperModel') || 'whisper-1'; + } + + async transcribe(audioBuffer: Buffer): Promise { + const apiKey = this.configService.get('openai.apiKey'); + if (!apiKey) { + throw new Error('OpenAI API key not configured'); + } + + // Create a File-like object for the API + const file = new File([audioBuffer], 'audio.ogg', { type: 'audio/ogg' }); + + const response = await this.openai.audio.transcriptions.create({ + file, + model: this.model, + language: 'de', + }); + + return response.text; + } +} diff --git a/services/matrix-project-doc-bot/tsconfig.json b/services/matrix-project-doc-bot/tsconfig.json new file mode 100644 index 000000000..b439390d0 --- /dev/null +++ b/services/matrix-project-doc-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + } +} diff --git a/services/matrix-stats-bot/.env.example b/services/matrix-stats-bot/.env.example new file mode 100644 index 000000000..919ef25ee --- /dev/null +++ b/services/matrix-stats-bot/.env.example @@ -0,0 +1,16 @@ +PORT=3312 +TZ=Europe/Berlin + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_REPORT_ROOM_ID= +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# Umami +UMAMI_API_URL=http://localhost:3000 +UMAMI_USERNAME=admin +UMAMI_PASSWORD= + +# Database (optional, for user counts) +DATABASE_URL= diff --git a/services/matrix-stats-bot/CLAUDE.md b/services/matrix-stats-bot/CLAUDE.md new file mode 100644 index 000000000..402730a38 --- /dev/null +++ b/services/matrix-stats-bot/CLAUDE.md @@ -0,0 +1,65 @@ +# Matrix Stats Bot - Claude Code Guidelines + +## Overview + +Matrix Stats Bot delivers analytics from Umami (self-hosted) via Matrix. GDPR-compliant replacement for telegram-stats-bot. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk +- **Analytics**: Umami API +- **Scheduling**: @nestjs/schedule + +## Commands + +```bash +pnpm install +pnpm start:dev # Development with hot reload +pnpm build # Production build +pnpm type-check # TypeScript check +``` + +## Matrix Commands + +| Command | Description | +|---------|-------------| +| `!stats` | Overview of all apps (30 days) | +| `!today` | Today's statistics | +| `!week` | This week's statistics | +| `!realtime` | Active visitors right now | +| `!users` | Registered user statistics | +| `!help` | Show available commands | + +## Scheduled Reports + +| Report | Schedule | Timezone | +|--------|----------|----------| +| Daily | 09:00 | Europe/Berlin | +| Weekly | Monday 09:00 | Europe/Berlin | + +## Environment Variables + +```env +PORT=3312 +TZ=Europe/Berlin + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_REPORT_ROOM_ID=!roomid:mana.how + +# Umami +UMAMI_API_URL=http://umami:3000 +UMAMI_USERNAME=admin +UMAMI_PASSWORD=xxx + +# Database (for user counts) +DATABASE_URL=postgresql://... +``` + +## Health Check + +```bash +curl http://localhost:3312/health +``` diff --git a/services/matrix-stats-bot/Dockerfile b/services/matrix-stats-bot/Dockerfile new file mode 100644 index 000000000..ce56bc1ba --- /dev/null +++ b/services/matrix-stats-bot/Dockerfile @@ -0,0 +1,25 @@ +FROM node:20-alpine AS builder +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile || pnpm install +COPY . . +RUN pnpm build + +FROM node:20-alpine AS runner +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +RUN mkdir -p /app/data +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --prod --frozen-lockfile || pnpm install --prod +COPY --from=builder /app/dist ./dist +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nestjs +RUN chown -R nestjs:nodejs /app +USER nestjs + +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3312/health || exit 1 + +EXPOSE 3312 +CMD ["node", "dist/main.js"] diff --git a/services/matrix-stats-bot/nest-cli.json b/services/matrix-stats-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/matrix-stats-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-stats-bot/package.json b/services/matrix-stats-bot/package.json new file mode 100644 index 000000000..5cdd931dc --- /dev/null +++ b/services/matrix-stats-bot/package.json @@ -0,0 +1,36 @@ +{ + "name": "@manacore/matrix-stats-bot", + "version": "1.0.0", + "description": "Matrix bot for analytics from Umami - GDPR compliant", + "private": true, + "license": "MIT", + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "@nestjs/schedule": "^4.1.2", + "matrix-bot-sdk": "^0.7.1", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "rimraf": "^6.0.1", + "typescript": "^5.7.3" + } +} diff --git a/services/matrix-stats-bot/src/analytics/analytics.module.ts b/services/matrix-stats-bot/src/analytics/analytics.module.ts new file mode 100644 index 000000000..ab8df2d1d --- /dev/null +++ b/services/matrix-stats-bot/src/analytics/analytics.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AnalyticsService } from './analytics.service'; +import { UmamiModule } from '../umami/umami.module'; + +@Module({ + imports: [UmamiModule], + providers: [AnalyticsService], + exports: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/services/matrix-stats-bot/src/analytics/analytics.service.ts b/services/matrix-stats-bot/src/analytics/analytics.service.ts new file mode 100644 index 000000000..31d0d816e --- /dev/null +++ b/services/matrix-stats-bot/src/analytics/analytics.service.ts @@ -0,0 +1,129 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { UmamiService } from '../umami/umami.service'; +import { WEBSITE_IDS, DISPLAY_NAMES } from '../config/configuration'; + +@Injectable() +export class AnalyticsService { + private readonly logger = new Logger(AnalyticsService.name); + + constructor(private readonly umamiService: UmamiService) {} + + async generateStatsOverview(): Promise { + const now = Date.now(); + const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; + + const websites = await this.umamiService.getWebsites(); + if (!websites.length) { + return '❌ Keine Websites in Umami konfiguriert.'; + } + + let report = '**📊 ManaCore Stats (30 Tage)**\n\n'; + + for (const website of websites) { + const stats = await this.umamiService.getStats(website.id, thirtyDaysAgo, now); + if (!stats) continue; + + const displayName = DISPLAY_NAMES[website.name] || website.name; + const changeIcon = (change: number) => (change > 0 ? '📈' : change < 0 ? '📉' : '➡️'); + + report += `**${displayName}**\n`; + report += `👁️ ${stats.pageviews.value.toLocaleString()} Views ${changeIcon(stats.pageviews.change)}\n`; + report += `👥 ${stats.visitors.value.toLocaleString()} Besucher ${changeIcon(stats.visitors.change)}\n\n`; + } + + return report; + } + + async generateDailyReport(): Promise { + const now = Date.now(); + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + + const websites = await this.umamiService.getWebsites(); + if (!websites.length) { + return '❌ Keine Websites konfiguriert.'; + } + + let report = '**📊 Heute**\n\n'; + let totalViews = 0; + let totalVisitors = 0; + + for (const website of websites) { + const stats = await this.umamiService.getStats(website.id, todayStart.getTime(), now); + if (!stats) continue; + + const displayName = DISPLAY_NAMES[website.name] || website.name; + totalViews += stats.pageviews.value; + totalVisitors += stats.visitors.value; + + if (stats.pageviews.value > 0) { + report += `**${displayName}:** ${stats.pageviews.value} Views, ${stats.visitors.value} Besucher\n`; + } + } + + report += `\n**Gesamt:** ${totalViews} Views, ${totalVisitors} Besucher`; + + return report; + } + + async generateWeeklyReport(): Promise { + const now = Date.now(); + const weekAgo = now - 7 * 24 * 60 * 60 * 1000; + + const websites = await this.umamiService.getWebsites(); + if (!websites.length) { + return '❌ Keine Websites konfiguriert.'; + } + + let report = '**📊 Diese Woche**\n\n'; + let totalViews = 0; + let totalVisitors = 0; + + for (const website of websites) { + const stats = await this.umamiService.getStats(website.id, weekAgo, now); + if (!stats) continue; + + const displayName = DISPLAY_NAMES[website.name] || website.name; + totalViews += stats.pageviews.value; + totalVisitors += stats.visitors.value; + + const changeIcon = (change: number) => (change > 0 ? '📈' : change < 0 ? '📉' : '➡️'); + + report += `**${displayName}**\n`; + report += `👁️ ${stats.pageviews.value.toLocaleString()} Views ${changeIcon(stats.pageviews.change)} (${stats.pageviews.change > 0 ? '+' : ''}${stats.pageviews.change}%)\n`; + report += `👥 ${stats.visitors.value.toLocaleString()} Besucher ${changeIcon(stats.visitors.change)}\n\n`; + } + + report += `**Gesamt:** ${totalViews.toLocaleString()} Views, ${totalVisitors.toLocaleString()} Besucher`; + + return report; + } + + async generateRealtimeReport(): Promise { + const websites = await this.umamiService.getWebsites(); + if (!websites.length) { + return '❌ Keine Websites konfiguriert.'; + } + + let report = '**🔴 Realtime**\n\n'; + let totalActive = 0; + + for (const website of websites) { + const realtime = await this.umamiService.getRealtime(website.id); + if (!realtime || realtime.visitors === 0) continue; + + const displayName = DISPLAY_NAMES[website.name] || website.name; + totalActive += realtime.visitors; + + report += `**${displayName}:** ${realtime.visitors} aktiv\n`; + } + + if (totalActive === 0) { + report += 'Keine aktiven Besucher.'; + } else { + report += `\n**Gesamt:** ${totalActive} aktive Besucher`; + } + + return report; + } +} diff --git a/services/matrix-stats-bot/src/app.module.ts b/services/matrix-stats-bot/src/app.module.ts new file mode 100644 index 000000000..825025743 --- /dev/null +++ b/services/matrix-stats-bot/src/app.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { BotModule } from './bot/bot.module'; +import { SchedulerModule } from './scheduler/scheduler.module'; +import { HealthController } from './health.controller'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + SchedulerModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/matrix-stats-bot/src/bot/bot.module.ts b/services/matrix-stats-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..329152f08 --- /dev/null +++ b/services/matrix-stats-bot/src/bot/bot.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { AnalyticsModule } from '../analytics/analytics.module'; +import { UsersModule } from '../users/users.module'; + +@Module({ + imports: [AnalyticsModule, UsersModule], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-stats-bot/src/bot/matrix.service.ts b/services/matrix-stats-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..666a81f72 --- /dev/null +++ b/services/matrix-stats-bot/src/bot/matrix.service.ts @@ -0,0 +1,196 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MatrixClient, + SimpleFsStorageProvider, + AutojoinRoomsMixin, + RichConsoleLogger, + LogService, + MessageEvent, + RoomEvent, +} from 'matrix-bot-sdk'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { UsersService } from '../users/users.service'; + +@Injectable() +export class MatrixService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MatrixService.name); + private client!: MatrixClient; + private botUserId: string = ''; + private reportRoomId: string = ''; + + constructor( + private configService: ConfigService, + private analyticsService: AnalyticsService, + private usersService: UsersService + ) { + this.reportRoomId = this.configService.get('matrix.reportRoomId') || ''; + } + + 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(LogService.LogLevel.INFO); + + const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json'); + this.client = new MatrixClient(homeserverUrl!, accessToken, storage); + + AutojoinRoomsMixin.setupOnClient(this.client); + + 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 Stats Bot started successfully'); + } + + async onModuleDestroy() { + if (this.client) { + await this.client.stop(); + this.logger.log('Matrix Stats Bot stopped'); + } + } + + private async handleRoomMessage(roomId: string, event: RoomEvent) { + if (event.sender === this.botUserId) return; + + const content = event.content; + if (content.msgtype !== 'm.text') return; + + const body = content.body; + if (!body || !body.startsWith('!')) return; + + const [command] = body.slice(1).split(' '); + await this.handleCommand(roomId, command.toLowerCase()); + } + + private async handleCommand(roomId: string, command: string) { + switch (command) { + case 'help': + case 'start': + await this.sendHelp(roomId); + break; + + case 'stats': + await this.sendStats(roomId); + break; + + case 'today': + await this.sendToday(roomId); + break; + + case 'week': + await this.sendWeek(roomId); + break; + + case 'realtime': + await this.sendRealtime(roomId); + break; + + case 'users': + await this.sendUsers(roomId); + break; + + default: + await this.sendMessage(roomId, `Unbekannter Befehl: !${command}\n\nVerwende !help`); + } + } + + private async sendHelp(roomId: string) { + const helpText = `**📊 ManaCore Stats Bot (DSGVO-konform)** + +**Befehle:** +- \`!stats\` - Übersicht aller Apps (30 Tage) +- \`!today\` - Heutige Statistiken +- \`!week\` - Wochenstatistiken +- \`!realtime\` - Aktive Besucher jetzt +- \`!users\` - Registrierte Benutzer +- \`!help\` - Diese Hilfe + +Daten von Umami Analytics (self-hosted).`; + + await this.sendMessage(roomId, helpText); + } + + private async sendStats(roomId: string) { + await this.sendMessage(roomId, '📊 Lade Statistiken...'); + const report = await this.analyticsService.generateStatsOverview(); + await this.sendMessage(roomId, report); + } + + private async sendToday(roomId: string) { + await this.sendMessage(roomId, '📊 Lade heutige Statistiken...'); + const report = await this.analyticsService.generateDailyReport(); + await this.sendMessage(roomId, report); + } + + private async sendWeek(roomId: string) { + await this.sendMessage(roomId, '📊 Lade Wochenstatistiken...'); + const report = await this.analyticsService.generateWeeklyReport(); + await this.sendMessage(roomId, report); + } + + private async sendRealtime(roomId: string) { + const report = await this.analyticsService.generateRealtimeReport(); + await this.sendMessage(roomId, report); + } + + private async sendUsers(roomId: string) { + const stats = await this.usersService.getUserStats(); + + if (!stats) { + await this.sendMessage(roomId, '❌ Datenbank nicht verfügbar.'); + return; + } + + const report = `**👥 Benutzer-Statistiken** + +**Gesamt:** ${stats.total} Benutzer +**Verifiziert:** ${stats.verified} (${((stats.verified / stats.total) * 100).toFixed(1)}%) + +**Neue Benutzer:** +- Letzte 7 Tage: ${stats.lastWeek} +- Letzte 30 Tage: ${stats.lastMonth}`; + + await this.sendMessage(roomId, report); + } + + // Public method for scheduled reports + async sendScheduledReport(report: string) { + if (!this.reportRoomId) { + this.logger.warn('No report room configured'); + return; + } + + await this.sendMessage(this.reportRoomId, report); + } + + 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(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\n/g, '
'); + } +} diff --git a/services/matrix-stats-bot/src/config/configuration.ts b/services/matrix-stats-bot/src/config/configuration.ts new file mode 100644 index 000000000..90a0aedd6 --- /dev/null +++ b/services/matrix-stats-bot/src/config/configuration.ts @@ -0,0 +1,39 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3312', 10), + timezone: process.env.TZ || 'Europe/Berlin', + matrix: { + homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008', + accessToken: process.env.MATRIX_ACCESS_TOKEN || '', + reportRoomId: process.env.MATRIX_REPORT_ROOM_ID || '', + storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json', + }, + umami: { + apiUrl: process.env.UMAMI_API_URL || 'http://localhost:3000', + username: process.env.UMAMI_USERNAME || 'admin', + password: process.env.UMAMI_PASSWORD || '', + }, + database: { + url: process.env.DATABASE_URL || '', + }, +}); + +// Website IDs from Umami - update these with actual UUIDs +export const WEBSITE_IDS: Record = { + 'manacore-webapp': process.env.UMAMI_WEBSITE_MANACORE || '', + 'chat-webapp': process.env.UMAMI_WEBSITE_CHAT || '', + 'todo-webapp': process.env.UMAMI_WEBSITE_TODO || '', + 'calendar-webapp': process.env.UMAMI_WEBSITE_CALENDAR || '', + 'clock-webapp': process.env.UMAMI_WEBSITE_CLOCK || '', + 'contacts-webapp': process.env.UMAMI_WEBSITE_CONTACTS || '', + 'storage-webapp': process.env.UMAMI_WEBSITE_STORAGE || '', +}; + +export const DISPLAY_NAMES: Record = { + 'manacore-webapp': 'Dashboard', + 'chat-webapp': 'Chat', + 'todo-webapp': 'Todo', + 'calendar-webapp': 'Calendar', + 'clock-webapp': 'Clock', + 'contacts-webapp': 'Contacts', + 'storage-webapp': 'Storage', +}; diff --git a/services/matrix-stats-bot/src/health.controller.ts b/services/matrix-stats-bot/src/health.controller.ts new file mode 100644 index 000000000..44275b13b --- /dev/null +++ b/services/matrix-stats-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-stats-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/matrix-stats-bot/src/main.ts b/services/matrix-stats-bot/src/main.ts new file mode 100644 index 000000000..4751306f3 --- /dev/null +++ b/services/matrix-stats-bot/src/main.ts @@ -0,0 +1,15 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { Logger } from '@nestjs/common'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + const port = process.env.PORT || 3312; + await app.listen(port); + + logger.log(`Matrix Stats Bot running on port ${port}`); + logger.log(`Health check: http://localhost:${port}/health`); +} +bootstrap(); diff --git a/services/matrix-stats-bot/src/scheduler/report.scheduler.ts b/services/matrix-stats-bot/src/scheduler/report.scheduler.ts new file mode 100644 index 000000000..b193fd0a0 --- /dev/null +++ b/services/matrix-stats-bot/src/scheduler/report.scheduler.ts @@ -0,0 +1,30 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { MatrixService } from '../bot/matrix.service'; +import { AnalyticsService } from '../analytics/analytics.service'; + +@Injectable() +export class ReportScheduler { + private readonly logger = new Logger(ReportScheduler.name); + + constructor( + private readonly matrixService: MatrixService, + private readonly analyticsService: AnalyticsService + ) {} + + // Daily report at 9:00 AM Berlin time + @Cron('0 9 * * *', { timeZone: 'Europe/Berlin' }) + async sendDailyReport() { + this.logger.log('Sending daily report...'); + const report = await this.analyticsService.generateDailyReport(); + await this.matrixService.sendScheduledReport(`📅 **Täglicher Report**\n\n${report}`); + } + + // Weekly report on Monday at 9:00 AM Berlin time + @Cron('0 9 * * 1', { timeZone: 'Europe/Berlin' }) + async sendWeeklyReport() { + this.logger.log('Sending weekly report...'); + const report = await this.analyticsService.generateWeeklyReport(); + await this.matrixService.sendScheduledReport(`📅 **Wöchentlicher Report**\n\n${report}`); + } +} diff --git a/services/matrix-stats-bot/src/scheduler/scheduler.module.ts b/services/matrix-stats-bot/src/scheduler/scheduler.module.ts new file mode 100644 index 000000000..51a1ad147 --- /dev/null +++ b/services/matrix-stats-bot/src/scheduler/scheduler.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ReportScheduler } from './report.scheduler'; +import { BotModule } from '../bot/bot.module'; +import { AnalyticsModule } from '../analytics/analytics.module'; + +@Module({ + imports: [ScheduleModule.forRoot(), BotModule, AnalyticsModule], + providers: [ReportScheduler], +}) +export class SchedulerModule {} diff --git a/services/matrix-stats-bot/src/umami/umami.module.ts b/services/matrix-stats-bot/src/umami/umami.module.ts new file mode 100644 index 000000000..b9bb7b2bd --- /dev/null +++ b/services/matrix-stats-bot/src/umami/umami.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { UmamiService } from './umami.service'; + +@Module({ + providers: [UmamiService], + exports: [UmamiService], +}) +export class UmamiModule {} diff --git a/services/matrix-stats-bot/src/umami/umami.service.ts b/services/matrix-stats-bot/src/umami/umami.service.ts new file mode 100644 index 000000000..44b61406e --- /dev/null +++ b/services/matrix-stats-bot/src/umami/umami.service.ts @@ -0,0 +1,114 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface UmamiStats { + pageviews: { value: number; change: number }; + visitors: { value: number; change: number }; + visits: { value: number; change: number }; + bounces: { value: number; change: number }; + totaltime: { value: number; change: number }; +} + +interface UmamiRealtime { + pageviews: number; + visitors: number; + countries: { name: string; count: number }[]; +} + +@Injectable() +export class UmamiService implements OnModuleInit { + private readonly logger = new Logger(UmamiService.name); + private readonly apiUrl: string; + private readonly username: string; + private readonly password: string; + private accessToken: string | null = null; + + constructor(private configService: ConfigService) { + this.apiUrl = this.configService.get('umami.apiUrl') || 'http://localhost:3000'; + this.username = this.configService.get('umami.username') || 'admin'; + this.password = this.configService.get('umami.password') || ''; + } + + async onModuleInit() { + await this.authenticate(); + } + + private async authenticate(): Promise { + try { + const response = await fetch(`${this.apiUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: this.username, + password: this.password, + }), + }); + + if (!response.ok) { + throw new Error(`Auth failed: ${response.status}`); + } + + const data = await response.json(); + this.accessToken = data.token; + this.logger.log('Umami authenticated successfully'); + } catch (error) { + this.logger.error('Failed to authenticate with Umami:', error); + } + } + + private async request(endpoint: string): Promise { + if (!this.accessToken) { + await this.authenticate(); + } + + try { + const response = await fetch(`${this.apiUrl}${endpoint}`, { + headers: { + Authorization: `Bearer ${this.accessToken}`, + }, + }); + + if (response.status === 401) { + await this.authenticate(); + return this.request(endpoint); + } + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + + return response.json(); + } catch (error) { + this.logger.error(`Umami request failed: ${endpoint}`, error); + return null; + } + } + + async getWebsites(): Promise<{ id: string; name: string; domain: string }[]> { + const data = await this.request<{ data: { id: string; name: string; domain: string }[] }>( + '/api/websites' + ); + return data?.data || []; + } + + async getStats(websiteId: string, startAt: number, endAt: number): Promise { + return this.request( + `/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}` + ); + } + + async getRealtime(websiteId: string): Promise { + return this.request(`/api/websites/${websiteId}/active`); + } + + async getPageviews( + websiteId: string, + startAt: number, + endAt: number, + unit: 'hour' | 'day' | 'month' = 'day' + ): Promise<{ pageviews: { x: string; y: number }[]; sessions: { x: string; y: number }[] } | null> { + return this.request( + `/api/websites/${websiteId}/pageviews?startAt=${startAt}&endAt=${endAt}&unit=${unit}` + ); + } +} diff --git a/services/matrix-stats-bot/src/users/users.module.ts b/services/matrix-stats-bot/src/users/users.module.ts new file mode 100644 index 000000000..00ef465ea --- /dev/null +++ b/services/matrix-stats-bot/src/users/users.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { UsersService } from './users.service'; + +@Module({ + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/services/matrix-stats-bot/src/users/users.service.ts b/services/matrix-stats-bot/src/users/users.service.ts new file mode 100644 index 000000000..5797389af --- /dev/null +++ b/services/matrix-stats-bot/src/users/users.service.ts @@ -0,0 +1,55 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import postgres from 'postgres'; + +interface UserStats { + total: number; + verified: number; + lastWeek: number; + lastMonth: number; +} + +@Injectable() +export class UsersService implements OnModuleInit { + private readonly logger = new Logger(UsersService.name); + private sql: postgres.Sql | null = null; + + constructor(private configService: ConfigService) {} + + async onModuleInit() { + const databaseUrl = this.configService.get('database.url'); + if (databaseUrl) { + try { + this.sql = postgres(databaseUrl); + this.logger.log('Database connected for user stats'); + } catch (error) { + this.logger.warn('Failed to connect to database:', error); + } + } else { + this.logger.warn('DATABASE_URL not configured - user stats disabled'); + } + } + + async getUserStats(): Promise { + if (!this.sql) { + return null; + } + + try { + const [totalResult] = await this.sql`SELECT COUNT(*) as count FROM "user"`; + const [verifiedResult] = await this.sql`SELECT COUNT(*) as count FROM "user" WHERE "emailVerified" = true`; + const [weekResult] = await this.sql`SELECT COUNT(*) as count FROM "user" WHERE "createdAt" > NOW() - INTERVAL '7 days'`; + const [monthResult] = await this.sql`SELECT COUNT(*) as count FROM "user" WHERE "createdAt" > NOW() - INTERVAL '30 days'`; + + return { + total: parseInt(totalResult.count, 10), + verified: parseInt(verifiedResult.count, 10), + lastWeek: parseInt(weekResult.count, 10), + lastMonth: parseInt(monthResult.count, 10), + }; + } catch (error) { + this.logger.error('Failed to get user stats:', error); + return null; + } + } +} diff --git a/services/matrix-stats-bot/tsconfig.json b/services/matrix-stats-bot/tsconfig.json new file mode 100644 index 000000000..b439390d0 --- /dev/null +++ b/services/matrix-stats-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + } +}