feat(matrix): add Stats Bot and Project Doc Bot services

Complete GDPR-compliant bot suite for Matrix:

matrix-stats-bot (port 3312):
- Analytics reports from Umami
- Commands: !stats, !today, !week, !realtime, !users
- Scheduled daily/weekly reports to Matrix room

matrix-project-doc-bot (port 3313):
- Project documentation with photos, voice, text
- Voice transcription via OpenAI Whisper
- Blog generation with 5 styles (casual, technical, tutorial, social, story)
- Commands: !new, !projects, !switch, !status, !generate, !export
- Uses PostgreSQL + S3 (MinIO) for storage

Changes:
- docker-compose.macmini.yml: Added both Matrix bots
- health-check.sh: Added health checks for both bots

Environment variables required:
- MATRIX_STATS_BOT_TOKEN, MATRIX_PROJECT_DOC_BOT_TOKEN
- OPENAI_API_KEY (for Project Doc Bot)

https://claude.ai/code/session_01E3r5aFW3YLAhEJfsL2ryhv
This commit is contained in:
Claude 2026-01-28 00:44:28 +00:00
parent aabe328b51
commit 7c5e9e3c49
No known key found for this signature in database
46 changed files with 2215 additions and 0 deletions

View file

@ -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

View file

@ -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
```

View file

@ -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"]

View file

@ -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 || '',
},
});

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -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"
}
}

View file

@ -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 {}

View file

@ -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 {}

View file

@ -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<string, string> = new Map();
constructor(
private configService: ConfigService,
private projectService: ProjectService,
private mediaService: MediaService,
private generationService: GenerationService
) {
this.allowedUsers = this.configService.get<string[]>('matrix.allowedUsers') || [];
}
async onModuleInit() {
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('matrix.storagePath');
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<MessageEvent>) {
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, '<pre><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/_([^_]+)_/g, '<em>$1</em>')
.replace(/\n/g, '<br/>');
}
}

View file

@ -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<string, { name: string; prompt: string }> = {
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.`,
},
};

View file

@ -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<string>('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 {}

View file

@ -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(),
});

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { GenerationService } from './generation.service';
@Module({
providers: [GenerationService],
exports: [GenerationService],
})
export class GenerationModule {}

View file

@ -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<typeof schema>;
@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<string>('openai.apiKey'),
});
this.model = this.configService.get<string>('openai.model') || 'gpt-4o-mini';
}
async generateBlogpost(projectId: string, style: keyof typeof BLOG_STYLES): Promise<string> {
const apiKey = this.configService.get<string>('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;
}
}

View file

@ -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(),
};
}
}

View file

@ -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();

View file

@ -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 {}

View file

@ -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<typeof schema>;
@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;
}
}

View file

@ -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<string>('s3.endpoint'),
region: this.configService.get<string>('s3.region'),
credentials: {
accessKeyId: this.configService.get<string>('s3.accessKey') || '',
secretAccessKey: this.configService.get<string>('s3.secretKey') || '',
},
forcePathStyle: true,
});
this.bucket = this.configService.get<string>('s3.bucket') || 'project-doc-bot';
}
async uploadFile(buffer: Buffer, contentType: string, projectId: string): Promise<string> {
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<string> {
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: key,
});
return getSignedUrl(this.s3Client, command, { expiresIn });
}
async downloadFile(key: string): Promise<Buffer> {
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<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'audio/ogg': '.ogg',
'audio/mpeg': '.mp3',
'audio/mp4': '.m4a',
};
return map[contentType] || '';
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ProjectService } from './project.service';
@Module({
providers: [ProjectService],
exports: [ProjectService],
})
export class ProjectModule {}

View file

@ -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<typeof schema>;
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<typeof projects.$inferInsert>) {
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);
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { TranscriptionService } from './transcription.service';
@Module({
providers: [TranscriptionService],
exports: [TranscriptionService],
})
export class TranscriptionModule {}

View file

@ -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<string>('openai.apiKey');
if (!apiKey) {
this.logger.warn('OPENAI_API_KEY not configured - transcription disabled');
}
this.openai = new OpenAI({ apiKey });
this.model = this.configService.get<string>('openai.whisperModel') || 'whisper-1';
}
async transcribe(audioBuffer: Buffer): Promise<string> {
const apiKey = this.configService.get<string>('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;
}
}

View file

@ -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
}
}

View file

@ -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=

View file

@ -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
```

View file

@ -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"]

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -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"
}
}

View file

@ -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 {}

View file

@ -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<string> {
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<string> {
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<string> {
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<string> {
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;
}
}

View file

@ -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 {}

View file

@ -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 {}

View file

@ -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<string>('matrix.reportRoomId') || '';
}
async onModuleInit() {
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('matrix.storagePath');
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<MessageEvent>) {
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, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\n/g, '<br/>');
}
}

View file

@ -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<string, string> = {
'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<string, string> = {
'manacore-webapp': 'Dashboard',
'chat-webapp': 'Chat',
'todo-webapp': 'Todo',
'calendar-webapp': 'Calendar',
'clock-webapp': 'Clock',
'contacts-webapp': 'Contacts',
'storage-webapp': 'Storage',
};

View file

@ -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(),
};
}
}

View file

@ -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();

View file

@ -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}`);
}
}

View file

@ -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 {}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { UmamiService } from './umami.service';
@Module({
providers: [UmamiService],
exports: [UmamiService],
})
export class UmamiModule {}

View file

@ -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<string>('umami.apiUrl') || 'http://localhost:3000';
this.username = this.configService.get<string>('umami.username') || 'admin';
this.password = this.configService.get<string>('umami.password') || '';
}
async onModuleInit() {
await this.authenticate();
}
private async authenticate(): Promise<void> {
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<T>(endpoint: string): Promise<T | null> {
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<UmamiStats | null> {
return this.request<UmamiStats>(
`/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`
);
}
async getRealtime(websiteId: string): Promise<UmamiRealtime | null> {
return this.request<UmamiRealtime>(`/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}`
);
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View file

@ -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<string>('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<UserStats | null> {
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;
}
}
}

View file

@ -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
}
}