feat(matrix-contacts-bot): add Matrix bot for contact management

- List, search, and view contact details
- Create, edit, and delete contacts
- Toggle favorites and archive status
- Number-based reference system for easy commands
- German and English command aliases
- Login/logout via mana-core-auth

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-30 16:20:11 +01:00
parent 8950692cfd
commit 64535373ac
18 changed files with 1572 additions and 0 deletions

34
pnpm-lock.yaml generated
View file

@ -5800,6 +5800,40 @@ importers:
specifier: ^5.7.3
version: 5.9.3
services/matrix-contacts-bot:
dependencies:
'@nestjs/common':
specifier: ^10.4.15
version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/config':
specifier: ^3.3.0
version: 3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.4.15
version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.4.15
version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)
matrix-bot-sdk:
specifier: ^0.7.1
version: 0.7.1
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
rxjs:
specifier: ^7.8.1
version: 7.8.2
devDependencies:
'@nestjs/cli':
specifier: ^10.4.9
version: 10.4.9
'@types/node':
specifier: ^22.10.2
version: 22.19.1
typescript:
specifier: ^5.7.2
version: 5.9.3
services/matrix-mana-bot:
dependencies:
'@manacore/bot-services':

View file

@ -0,0 +1,15 @@
# Server
PORT=3320
# Matrix
MATRIX_HOMESERVER_URL=http://localhost:8008
MATRIX_ACCESS_TOKEN=syt_xxx
MATRIX_ALLOWED_ROOMS=#contacts:matrix.mana.how
MATRIX_STORAGE_PATH=./data/bot-storage.json
# Contacts Backend
CONTACTS_BACKEND_URL=http://localhost:3015
CONTACTS_API_PREFIX=/api/v1
# Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001

29
services/matrix-contacts-bot/.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
# Dependencies
node_modules/
# Build output
dist/
# Environment
.env
.env.local
# Data
data/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# TypeScript
*.tsbuildinfo

View file

@ -0,0 +1,186 @@
# Matrix Contacts Bot - Claude Code Guidelines
## Overview
Matrix Contacts Bot provides contact management via Matrix chat. It integrates with the Contacts backend for full CRUD operations, search, favorites, and archiving.
## Tech Stack
- **Framework**: NestJS 10
- **Matrix**: matrix-bot-sdk
- **Backend**: Contacts API (port 3015)
- **Auth**: Mana Core Auth (JWT)
## Commands
```bash
# Development
pnpm install
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
```
## Project Structure
```
services/matrix-contacts-bot/
├── src/
│ ├── main.ts # Application entry point (port 3320)
│ ├── app.module.ts # Root module
│ ├── health.controller.ts # Health check endpoint
│ ├── config/
│ │ └── configuration.ts # Configuration & help messages
│ ├── bot/
│ │ ├── bot.module.ts
│ │ └── matrix.service.ts # Matrix client & command handlers
│ ├── contacts/
│ │ ├── contacts.module.ts
│ │ └── contacts.service.ts # Contacts Backend API client
│ └── session/
│ ├── session.module.ts
│ └── session.service.ts # User session & auth management
├── Dockerfile
└── package.json
```
## Bot Commands
| Command | Aliases | Description |
|---------|---------|-------------|
| `!help` | hilfe | Show help message |
| `!kontakte` | contacts, liste | List all contacts |
| `!suche [text]` | search | Search contacts |
| `!favoriten` | favorites | Show favorites |
| `!kontakt [nr]` | contact, details | Show contact details |
| `!neu Vorname [Nachname]` | new, add | Create new contact |
| `!edit [nr] [feld] [wert]` | bearbeiten | Edit contact field |
| `!loeschen [nr]` | delete | Delete contact |
| `!fav [nr]` | favorit | Toggle favorite |
| `!archiv [nr]` | archive | Toggle archive |
| `!login email pass` | - | Login |
| `!logout` | - | Logout |
| `!status` | - | Bot status |
## Editable Fields
| Field | Aliases | Description |
|-------|---------|-------------|
| `email` | - | Email address |
| `phone` | telefon | Phone number |
| `mobile` | mobil, handy | Mobile number |
| `company` | firma | Company name |
| `job` | jobtitle, beruf | Job title |
| `website` | web | Website URL |
| `street` | strasse | Street address |
| `city` | stadt | City |
| `zip` | plz | Postal code |
| `country` | land | Country |
| `notes` | notizen | Notes |
| `birthday` | geburtstag | Birthday (YYYY-MM-DD) |
## Example Usage
```
# Create a contact
!neu Max Mustermann
# Add email
!edit 1 email max@example.com
# Add phone
!edit 1 phone +49 123 456789
# Mark as favorite
!fav 1
# Search
!suche Muster
```
## Environment Variables
```env
# Server
PORT=3320
# Matrix
MATRIX_HOMESERVER_URL=http://localhost:8008
MATRIX_ACCESS_TOKEN=syt_xxx
MATRIX_ALLOWED_ROOMS=#contacts:matrix.mana.how
MATRIX_STORAGE_PATH=./data/bot-storage.json
# Contacts Backend
CONTACTS_BACKEND_URL=http://localhost:3015
CONTACTS_API_PREFIX=/api/v1
# Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001
```
## Docker
```bash
# Build locally
docker build -f services/matrix-contacts-bot/Dockerfile -t matrix-contacts-bot services/matrix-contacts-bot
# Run
docker run -p 3320:3320 \
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
-e MATRIX_ACCESS_TOKEN=syt_xxx \
-e CONTACTS_BACKEND_URL=http://contacts-backend:3015 \
-e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \
-v matrix-contacts-bot-data:/app/data \
matrix-contacts-bot
```
## Health Check
```bash
curl http://localhost:3320/health
```
## Getting a Matrix Access Token
```bash
# Create bot user first, then login
curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \
-H "Content-Type: application/json" \
-d '{
"type": "m.login.password",
"user": "contacts-bot",
"password": "your-password"
}'
# Response contains: {"access_token": "syt_xxx", ...}
```
## Contacts Backend API Endpoints Used
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check |
| `/api/v1/contacts` | GET | List contacts with filters |
| `/api/v1/contacts` | POST | Create contact |
| `/api/v1/contacts/:id` | GET | Get contact details |
| `/api/v1/contacts/:id` | PATCH | Update contact |
| `/api/v1/contacts/:id` | DELETE | Delete contact |
| `/api/v1/contacts/:id/favorite` | POST | Toggle favorite |
| `/api/v1/contacts/:id/archive` | POST | Toggle archive |
## Number-Based Reference System
The bot uses a number-based reference system for ease of use:
1. User runs `!kontakte` or `!suche` to get a list
2. Bot stores the list internally for the user
3. User can reference contacts by their list number
4. Numbers are valid until the user runs a new list command
This allows simple commands like:
- `!kontakt 3` - Show details for contact #3 in the list
- `!edit 1 email new@email.com` - Edit contact #1
- `!fav 2` - Toggle favorite for contact #2

View file

@ -0,0 +1,41 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy package files and install production dependencies only
COPY package.json ./
RUN npm install --omit=dev
# Copy built application from builder
COPY --from=builder /app/dist ./dist
# Create data directory
RUN mkdir -p /app/data
# Expose port
EXPOSE 3320
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3320/health || exit 1
# Start the application
CMD ["node", "dist/main.js"]

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,39 @@
{
"name": "@manacore/matrix-contacts-bot",
"version": "1.0.0",
"description": "Matrix bot for contact management via Contacts backend",
"private": true,
"pnpm": {
"neverBuiltDependencies": [
"@matrix-org/matrix-sdk-crypto-nodejs"
],
"overrides": {
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
}
},
"overrides": {
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
},
"scripts": {
"prebuild": "rm -rf dist || true",
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"matrix-bot-sdk": "^0.7.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import configuration from './config/configuration';
import { BotModule } from './bot/bot.module';
import { HealthController } from './health.controller';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
BotModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MatrixService } from './matrix.service';
import { ContactsModule } from '../contacts/contacts.module';
import { SessionModule } from '../session/session.module';
@Module({
imports: [ContactsModule, SessionModule],
providers: [MatrixService],
exports: [MatrixService],
})
export class BotModule {}

View file

@ -0,0 +1,733 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
MatrixClient,
SimpleFsStorageProvider,
RichConsoleLogger,
LogService,
LogLevel,
} from 'matrix-bot-sdk';
import { ContactsService, Contact } from '../contacts/contacts.service';
import { SessionService } from '../session/session.service';
import { HELP_MESSAGE } from '../config/configuration';
// Natural language keywords
const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [
{ keywords: ['hilfe', 'help', 'befehle', 'commands'], command: 'help' },
{ keywords: ['kontakte', 'contacts', 'alle'], command: 'kontakte' },
{ keywords: ['favoriten', 'favorites', 'favs'], command: 'favoriten' },
{ keywords: ['suche', 'search', 'finde'], command: 'suche' },
{ keywords: ['status', 'info'], command: 'status' },
];
@Injectable()
export class MatrixService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MatrixService.name);
private client!: MatrixClient;
private readonly allowedRooms: string[];
private botUserId: string = '';
// Store last shown contacts per user for reference by number
private lastContactsList: Map<string, Contact[]> = new Map();
constructor(
private configService: ConfigService,
private contactsService: ContactsService,
private sessionService: SessionService
) {
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms') || [];
}
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(LogLevel.INFO);
const storage = new SimpleFsStorageProvider(storagePath || './data/bot-storage.json');
this.client = new MatrixClient(homeserverUrl!, accessToken, storage);
this.client.on('room.invite', async (roomId: string) => {
this.logger.log(`Invited to room ${roomId}, joining...`);
await this.client.joinRoom(roomId);
setTimeout(async () => {
try {
await this.sendBotIntroduction(roomId);
} catch (error) {
this.logger.error(`Failed to send introduction to ${roomId}:`, error);
}
}, 2000);
});
this.botUserId = await this.client.getUserId();
this.logger.log(`Bot user ID: ${this.botUserId}`);
this.client.on('room.message', this.handleRoomMessage.bind(this));
await this.client.start();
this.logger.log('Matrix Contacts Bot started successfully');
}
async onModuleDestroy() {
if (this.client) {
await this.client.stop();
this.logger.log('Matrix bot stopped');
}
}
private async sendBotIntroduction(roomId: string) {
const introText = `**Contacts Bot - Kontaktverwaltung**
Ich helfe dir, deine Kontakte zu verwalten!
**Schnellstart:**
\`!kontakte\` - Alle Kontakte anzeigen
\`!suche Max\` - Kontakte suchen
\`!neu Vorname Nachname\` - Neuen Kontakt
Sag "hilfe" fur alle Befehle!`;
await this.sendMessage(roomId, introText);
}
private isRoomAllowed(roomId: string): boolean {
if (this.allowedRooms.length === 0) return true;
return this.allowedRooms.some((allowed) => roomId === allowed || roomId.includes(allowed));
}
private async handleRoomMessage(roomId: string, event: any) {
if (event.sender === this.botUserId) return;
if (!this.isRoomAllowed(roomId)) {
this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`);
return;
}
const content = event.content as { msgtype?: string; body?: string };
if (content.msgtype !== 'm.text') return;
const body = content.body;
if (!body) return;
this.logger.log(`Message from ${event.sender} in ${roomId}: ${body.substring(0, 50)}...`);
if (body.startsWith('!')) {
await this.handleCommand(roomId, event.sender, body);
return;
}
const keywordCommand = this.detectKeywordCommand(body);
if (keywordCommand) {
await this.handleCommand(roomId, event.sender, `!${keywordCommand}`);
return;
}
}
private detectKeywordCommand(message: string): string | null {
const lowerMessage = message.toLowerCase().trim();
if (lowerMessage.length > 30) return null;
for (const { keywords, command } of KEYWORD_COMMANDS) {
for (const keyword of keywords) {
if (lowerMessage === keyword || lowerMessage.startsWith(keyword + ' ')) {
return command;
}
}
}
return null;
}
private async handleCommand(roomId: string, sender: string, body: string) {
const [command, ...args] = body.slice(1).split(' ');
const argString = args.join(' ');
switch (command.toLowerCase()) {
case 'help':
case 'hilfe':
case 'start':
await this.sendHelp(roomId);
break;
case 'kontakte':
case 'contacts':
case 'liste':
case 'list':
await this.handleListContacts(roomId, sender);
break;
case 'suche':
case 'search':
await this.handleSearch(roomId, sender, argString);
break;
case 'favoriten':
case 'favorites':
case 'favs':
await this.handleFavorites(roomId, sender);
break;
case 'kontakt':
case 'contact':
case 'details':
await this.handleContactDetails(roomId, sender, args);
break;
case 'neu':
case 'new':
case 'add':
await this.handleCreateContact(roomId, sender, args);
break;
case 'edit':
case 'bearbeiten':
await this.handleEditContact(roomId, sender, args);
break;
case 'loeschen':
case 'delete':
case 'del':
await this.handleDeleteContact(roomId, sender, args);
break;
case 'fav':
case 'favorit':
await this.handleToggleFavorite(roomId, sender, args);
break;
case 'archiv':
case 'archive':
await this.handleToggleArchive(roomId, sender, args);
break;
case 'login':
await this.handleLogin(roomId, sender, args);
break;
case 'logout':
this.sessionService.logout(sender);
await this.sendMessage(roomId, 'Du wurdest abgemeldet.');
break;
case 'status':
await this.handleStatus(roomId, sender);
break;
case 'pin':
await this.pinHelpMessage(roomId);
break;
default:
await this.sendMessage(
roomId,
`Unbekannter Befehl: !${command}\n\nSag "hilfe" fur alle Befehle.`
);
}
}
private async handleListContacts(roomId: string, sender: string) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
try {
const result = await this.contactsService.getContacts(token, { limit: 20 });
const contacts = result.contacts;
if (contacts.length === 0) {
await this.sendMessage(
roomId,
`Du hast noch keine Kontakte.\n\nNutze \`!neu Vorname Nachname\` um einen zu erstellen.`
);
return;
}
// Store for reference
this.lastContactsList.set(sender, contacts);
let text = `**Deine Kontakte (${result.total}):**\n\n`;
for (let i = 0; i < contacts.length; i++) {
const c = contacts[i];
const name = c.displayName || `${c.firstName || ''} ${c.lastName || ''}`.trim() || 'Unbenannt';
const favIcon = c.isFavorite ? ' ★' : '';
const company = c.company ? ` - ${c.company}` : '';
text += `**${i + 1}.** ${name}${favIcon}${company}\n`;
}
if (result.total > 20) {
text += `\n_...und ${result.total - 20} weitere_`;
}
text += `\n\nNutze \`!kontakt [nr]\` fur Details.`;
await this.sendMessage(roomId, text);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private async handleSearch(roomId: string, sender: string, searchTerm: string) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
if (!searchTerm.trim()) {
await this.sendMessage(roomId, `**Verwendung:** \`!suche [text]\`\n\nBeispiel: \`!suche Max\``);
return;
}
try {
const result = await this.contactsService.getContacts(token, { search: searchTerm, limit: 20 });
const contacts = result.contacts;
if (contacts.length === 0) {
await this.sendMessage(roomId, `Keine Kontakte gefunden fur: "${searchTerm}"`);
return;
}
this.lastContactsList.set(sender, contacts);
let text = `**Suchergebnisse fur "${searchTerm}" (${contacts.length}):**\n\n`;
for (let i = 0; i < contacts.length; i++) {
const c = contacts[i];
const name = c.displayName || `${c.firstName || ''} ${c.lastName || ''}`.trim() || 'Unbenannt';
const favIcon = c.isFavorite ? ' ★' : '';
const email = c.email ? ` (${c.email})` : '';
text += `**${i + 1}.** ${name}${favIcon}${email}\n`;
}
await this.sendMessage(roomId, text);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private async handleFavorites(roomId: string, sender: string) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
try {
const result = await this.contactsService.getContacts(token, { isFavorite: true, limit: 20 });
const contacts = result.contacts;
if (contacts.length === 0) {
await this.sendMessage(
roomId,
`Du hast noch keine Favoriten.\n\nNutze \`!fav [nr]\` um einen Kontakt als Favorit zu markieren.`
);
return;
}
this.lastContactsList.set(sender, contacts);
let text = `**Deine Favoriten (${contacts.length}):**\n\n`;
for (let i = 0; i < contacts.length; i++) {
const c = contacts[i];
const name = c.displayName || `${c.firstName || ''} ${c.lastName || ''}`.trim() || 'Unbenannt';
const phone = c.phone || c.mobile || '';
text += `**${i + 1}.** ★ ${name}${phone ? ` - ${phone}` : ''}\n`;
}
await this.sendMessage(roomId, text);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private async handleContactDetails(roomId: string, sender: string, args: string[]) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
if (args.length < 1) {
await this.sendMessage(roomId, `**Verwendung:** \`!kontakt [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`);
return;
}
const index = parseInt(args[0], 10);
if (isNaN(index) || index < 1) {
await this.sendMessage(roomId, `Ungultige Nummer.`);
return;
}
const contacts = this.lastContactsList.get(sender);
if (!contacts || index > contacts.length) {
await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`);
return;
}
const contact = contacts[index - 1];
try {
const details = await this.contactsService.getContact(token, contact.id);
let text = `**${details.displayName || `${details.firstName || ''} ${details.lastName || ''}`.trim()}**\n\n`;
if (details.isFavorite) text += `★ Favorit\n\n`;
if (details.company || details.jobTitle) {
const job = [details.jobTitle, details.company].filter(Boolean).join(' bei ');
text += `**Beruf:** ${job}\n`;
}
if (details.email) text += `**E-Mail:** ${details.email}\n`;
if (details.phone) text += `**Telefon:** ${details.phone}\n`;
if (details.mobile) text += `**Mobil:** ${details.mobile}\n`;
if (details.street || details.city) {
const address = [details.street, `${details.postalCode || ''} ${details.city || ''}`.trim(), details.country]
.filter(Boolean)
.join(', ');
if (address) text += `**Adresse:** ${address}\n`;
}
if (details.website) text += `**Website:** ${details.website}\n`;
if (details.birthday) text += `**Geburtstag:** ${new Date(details.birthday).toLocaleDateString('de-DE')}\n`;
if (details.notes) text += `\n**Notizen:** ${details.notes}\n`;
await this.sendMessage(roomId, text);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private async handleCreateContact(roomId: string, sender: string, args: string[]) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
if (args.length < 1) {
await this.sendMessage(
roomId,
`**Verwendung:** \`!neu Vorname [Nachname]\`\n\nBeispiel: \`!neu Max Mustermann\``
);
return;
}
const firstName = args[0];
const lastName = args.slice(1).join(' ') || undefined;
try {
const contact = await this.contactsService.createContact(token, {
firstName,
lastName,
});
const name = contact.displayName || `${firstName} ${lastName || ''}`.trim();
await this.sendMessage(
roomId,
`Kontakt **${name}** erstellt!\n\nNutze \`!kontakte\` um die Liste zu sehen oder \`!edit\` um weitere Daten hinzuzufugen.`
);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private async handleEditContact(roomId: string, sender: string, args: string[]) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
if (args.length < 3) {
await this.sendMessage(
roomId,
`**Verwendung:** \`!edit [nr] [feld] [wert]\`\n\n**Felder:** email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday\n\n**Beispiel:** \`!edit 1 email max@example.com\``
);
return;
}
const index = parseInt(args[0], 10);
const field = args[1].toLowerCase();
const value = args.slice(2).join(' ');
if (isNaN(index) || index < 1) {
await this.sendMessage(roomId, `Ungultige Nummer.`);
return;
}
const contacts = this.lastContactsList.get(sender);
if (!contacts || index > contacts.length) {
await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`);
return;
}
const contact = contacts[index - 1];
const fieldMap: Record<string, string> = {
email: 'email',
phone: 'phone',
telefon: 'phone',
mobile: 'mobile',
mobil: 'mobile',
handy: 'mobile',
company: 'company',
firma: 'company',
job: 'jobTitle',
jobtitle: 'jobTitle',
beruf: 'jobTitle',
website: 'website',
web: 'website',
street: 'street',
strasse: 'street',
city: 'city',
stadt: 'city',
zip: 'postalCode',
plz: 'postalCode',
country: 'country',
land: 'country',
notes: 'notes',
notizen: 'notes',
birthday: 'birthday',
geburtstag: 'birthday',
firstname: 'firstName',
vorname: 'firstName',
lastname: 'lastName',
nachname: 'lastName',
};
const mappedField = fieldMap[field];
if (!mappedField) {
await this.sendMessage(roomId, `Unbekanntes Feld: ${field}\n\n**Gultige Felder:** email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday`);
return;
}
try {
const updated = await this.contactsService.updateContact(token, contact.id, {
[mappedField]: value,
});
const name = updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim();
await this.sendMessage(roomId, `Kontakt **${name}** aktualisiert!\n\n**${field}:** ${value}`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private async handleDeleteContact(roomId: string, sender: string, args: string[]) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
if (args.length < 1) {
await this.sendMessage(roomId, `**Verwendung:** \`!loeschen [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`);
return;
}
const index = parseInt(args[0], 10);
if (isNaN(index) || index < 1) {
await this.sendMessage(roomId, `Ungultige Nummer.`);
return;
}
const contacts = this.lastContactsList.get(sender);
if (!contacts || index > contacts.length) {
await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`);
return;
}
const contact = contacts[index - 1];
const name = contact.displayName || `${contact.firstName || ''} ${contact.lastName || ''}`.trim();
try {
await this.contactsService.deleteContact(token, contact.id);
await this.sendMessage(roomId, `Kontakt **${name}** geloscht.`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private async handleToggleFavorite(roomId: string, sender: string, args: string[]) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
if (args.length < 1) {
await this.sendMessage(roomId, `**Verwendung:** \`!fav [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`);
return;
}
const index = parseInt(args[0], 10);
if (isNaN(index) || index < 1) {
await this.sendMessage(roomId, `Ungultige Nummer.`);
return;
}
const contacts = this.lastContactsList.get(sender);
if (!contacts || index > contacts.length) {
await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`);
return;
}
const contact = contacts[index - 1];
try {
const updated = await this.contactsService.toggleFavorite(token, contact.id);
const name = updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim();
const status = updated.isFavorite ? 'als Favorit markiert ★' : 'aus Favoriten entfernt';
await this.sendMessage(roomId, `**${name}** ${status}`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private async handleToggleArchive(roomId: string, sender: string, args: string[]) {
const token = this.sessionService.getToken(sender);
if (!token) {
await this.sendMessage(roomId, `Du bist nicht angemeldet. Nutze \`!login\` zuerst.`);
return;
}
if (args.length < 1) {
await this.sendMessage(roomId, `**Verwendung:** \`!archiv [nr]\`\n\nNutze \`!kontakte\` um die Liste zu sehen.`);
return;
}
const index = parseInt(args[0], 10);
if (isNaN(index) || index < 1) {
await this.sendMessage(roomId, `Ungultige Nummer.`);
return;
}
const contacts = this.lastContactsList.get(sender);
if (!contacts || index > contacts.length) {
await this.sendMessage(roomId, `Kontakt ${index} nicht gefunden. Nutze \`!kontakte\` zuerst.`);
return;
}
const contact = contacts[index - 1];
try {
const updated = await this.contactsService.toggleArchive(token, contact.id);
const name = updated.displayName || `${updated.firstName || ''} ${updated.lastName || ''}`.trim();
const status = updated.isArchived ? 'archiviert' : 'aus dem Archiv geholt';
await this.sendMessage(roomId, `**${name}** ${status}`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
await this.sendMessage(roomId, `Fehler: ${errorMsg}`);
}
}
private async sendHelp(roomId: string) {
await this.sendMessage(roomId, HELP_MESSAGE);
}
private async handleLogin(roomId: string, sender: string, args: string[]) {
if (args.length < 2) {
await this.sendMessage(
roomId,
`**Verwendung:** \`!login email passwort\`\n\nBeispiel: \`!login nutzer@example.com meinpasswort\``
);
return;
}
const [email, password] = args;
await this.sendMessage(roomId, 'Anmeldung lauft...');
const result = await this.sessionService.login(sender, email, password);
if (result.success) {
await this.sendMessage(
roomId,
`Erfolgreich angemeldet!\n\nNutze \`!kontakte\` um deine Kontakte zu sehen.`
);
} else {
await this.sendMessage(roomId, `Anmeldung fehlgeschlagen: ${result.error}`);
}
}
private async handleStatus(roomId: string, sender: string) {
const backendHealthy = await this.contactsService.checkHealth();
const isLoggedIn = this.sessionService.isLoggedIn(sender);
const sessionCount = this.sessionService.getSessionCount();
const statusText = `**Contacts Bot Status**
**Backend:** ${backendHealthy ? 'Online' : 'Offline'}
**Dein Status:** ${isLoggedIn ? 'Angemeldet' : 'Nicht angemeldet'}
**Aktive Sessions:** ${sessionCount}
${!isLoggedIn ? 'Nutze `!login email passwort` um dich anzumelden.' : ''}`;
await this.sendMessage(roomId, statusText);
}
private async pinHelpMessage(roomId: string) {
try {
const htmlBody = this.markdownToHtml(HELP_MESSAGE);
const eventId = await this.client.sendMessage(roomId, {
msgtype: 'm.text',
body: HELP_MESSAGE,
format: 'org.matrix.custom.html',
formatted_body: htmlBody,
});
await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', {
pinned: [eventId],
});
this.logger.log(`Pinned help message in room ${roomId}`);
} catch (error) {
this.logger.error(`Failed to pin help message:`, error);
await this.sendMessage(roomId, 'Fehler beim Pinnen der Hilfe.');
}
}
private async sendMessage(roomId: string, message: string) {
const htmlBody = this.markdownToHtml(message);
await this.client.sendMessage(roomId, {
msgtype: 'm.text',
body: message,
format: 'org.matrix.custom.html',
formatted_body: htmlBody,
});
}
private markdownToHtml(markdown: string): string {
return (
markdown
.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/_([^_]+)_/g, '<em>$1</em>')
.replace(/\n/g, '<br/>')
);
}
}

View file

@ -0,0 +1,48 @@
export default () => ({
port: parseInt(process.env.PORT || '3320', 10),
matrix: {
homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008',
accessToken: process.env.MATRIX_ACCESS_TOKEN || '',
allowedRooms: (process.env.MATRIX_ALLOWED_ROOMS || '').split(',').filter(Boolean),
storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json',
},
contacts: {
backendUrl: process.env.CONTACTS_BACKEND_URL || 'http://localhost:3015',
apiPrefix: process.env.CONTACTS_API_PREFIX || '/api/v1',
},
auth: {
url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
},
});
export const HELP_MESSAGE = `**Contacts Bot - Kontaktverwaltung**
**Kontakte anzeigen:**
- \`!kontakte\` - Alle Kontakte anzeigen
- \`!suche [text]\` - Kontakte suchen
- \`!favoriten\` - Favoriten anzeigen
- \`!kontakt [nr]\` - Kontakt-Details
**Kontakte verwalten:** (Login erforderlich)
- \`!neu Vorname Nachname\` - Neuen Kontakt erstellen
- \`!edit [nr] [feld] [wert]\` - Kontakt bearbeiten
- \`!loeschen [nr]\` - Kontakt loschen
- \`!fav [nr]\` - Favorit umschalten
- \`!archiv [nr]\` - Archivieren umschalten
**Felder fur !edit:**
- \`email\`, \`phone\`, \`mobile\`
- \`company\`, \`job\`, \`website\`
- \`street\`, \`city\`, \`zip\`, \`country\`
- \`notes\`, \`birthday\`
**Beispiele:**
\`!neu Max Mustermann\`
\`!edit 1 email max@example.com\`
\`!edit 1 phone +49 123 456789\`
**Sonstiges:**
- \`!login email passwort\` - Anmelden
- \`!logout\` - Abmelden
- \`!status\` - Bot-Status
- \`!help\` - Diese Hilfe`;

View file

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

View file

@ -0,0 +1,252 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface Contact {
id: string;
firstName?: string | null;
lastName?: string | null;
displayName?: string | null;
nickname?: string | null;
email?: string | null;
phone?: string | null;
mobile?: string | null;
street?: string | null;
city?: string | null;
postalCode?: string | null;
country?: string | null;
company?: string | null;
jobTitle?: string | null;
department?: string | null;
website?: string | null;
birthday?: string | null;
notes?: string | null;
photoUrl?: string | null;
isFavorite: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
export interface ContactFilters {
search?: string;
isFavorite?: boolean;
isArchived?: boolean;
limit?: number;
offset?: number;
}
export interface ContactsResult {
contacts: Contact[];
total: number;
}
export interface CreateContactDto {
firstName?: string;
lastName?: string;
displayName?: string;
email?: string;
phone?: string;
mobile?: string;
company?: string;
jobTitle?: string;
website?: string;
notes?: string;
}
@Injectable()
export class ContactsService {
private readonly logger = new Logger(ContactsService.name);
private readonly backendUrl: string;
private readonly apiPrefix: string;
constructor(private configService: ConfigService) {
this.backendUrl =
this.configService.get<string>('contacts.backendUrl') || 'http://localhost:3015';
this.apiPrefix = this.configService.get<string>('contacts.apiPrefix') || '/api/v1';
}
private getApiUrl(path: string): string {
return `${this.backendUrl}${this.apiPrefix}${path}`;
}
async checkHealth(): Promise<boolean> {
try {
const response = await fetch(`${this.backendUrl}/health`);
return response.ok;
} catch (error) {
this.logger.error('Health check failed:', error);
return false;
}
}
async getContacts(token: string, filters: ContactFilters = {}): Promise<ContactsResult> {
try {
const params = new URLSearchParams();
if (filters.search) params.set('search', filters.search);
if (filters.isFavorite !== undefined) params.set('isFavorite', String(filters.isFavorite));
if (filters.isArchived !== undefined) params.set('isArchived', String(filters.isArchived));
if (filters.limit) params.set('limit', String(filters.limit));
if (filters.offset) params.set('offset', String(filters.offset));
const url = `${this.getApiUrl('/contacts')}?${params.toString()}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch contacts: ${response.status}`);
}
return await response.json();
} catch (error) {
this.logger.error('Failed to fetch contacts:', error);
throw error;
}
}
async getContact(token: string, contactId: string): Promise<Contact> {
try {
const response = await fetch(this.getApiUrl(`/contacts/${contactId}`), {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('Kontakt nicht gefunden');
}
throw new Error(`Failed to fetch contact: ${response.status}`);
}
const data = await response.json();
return data.contact;
} catch (error) {
this.logger.error(`Failed to fetch contact ${contactId}:`, error);
throw error;
}
}
async createContact(token: string, data: CreateContactDto): Promise<Contact> {
try {
const response = await fetch(this.getApiUrl('/contacts'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Failed to create contact: ${response.status}`);
}
const result = await response.json();
return result.contact;
} catch (error) {
this.logger.error('Failed to create contact:', error);
throw error;
}
}
async updateContact(token: string, contactId: string, data: Partial<CreateContactDto>): Promise<Contact> {
try {
const response = await fetch(this.getApiUrl(`/contacts/${contactId}`), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('Kontakt nicht gefunden');
}
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Failed to update contact: ${response.status}`);
}
const result = await response.json();
return result.contact;
} catch (error) {
this.logger.error(`Failed to update contact ${contactId}:`, error);
throw error;
}
}
async deleteContact(token: string, contactId: string): Promise<void> {
try {
const response = await fetch(this.getApiUrl(`/contacts/${contactId}`), {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('Kontakt nicht gefunden');
}
throw new Error(`Failed to delete contact: ${response.status}`);
}
} catch (error) {
this.logger.error(`Failed to delete contact ${contactId}:`, error);
throw error;
}
}
async toggleFavorite(token: string, contactId: string): Promise<Contact> {
try {
const response = await fetch(this.getApiUrl(`/contacts/${contactId}/favorite`), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('Kontakt nicht gefunden');
}
throw new Error(`Failed to toggle favorite: ${response.status}`);
}
const result = await response.json();
return result.contact;
} catch (error) {
this.logger.error(`Failed to toggle favorite for ${contactId}:`, error);
throw error;
}
}
async toggleArchive(token: string, contactId: string): Promise<Contact> {
try {
const response = await fetch(this.getApiUrl(`/contacts/${contactId}/archive`), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('Kontakt nicht gefunden');
}
throw new Error(`Failed to toggle archive: ${response.status}`);
}
const result = await response.json();
return result.contact;
} catch (error) {
this.logger.error(`Failed to toggle archive for ${contactId}:`, error);
throw error;
}
}
}

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'matrix-contacts-bot',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -0,0 +1,17 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const port = process.env.PORT || 3320;
await app.listen(port);
logger.log(`Matrix Contacts Bot running on port ${port}`);
logger.log(`Health check: http://localhost:${port}/health`);
}
bootstrap();

View file

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

View file

@ -0,0 +1,90 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
interface UserSession {
token: string;
email: string;
expiresAt: Date;
}
@Injectable()
export class SessionService {
private readonly logger = new Logger(SessionService.name);
private sessions: Map<string, UserSession> = new Map();
private authUrl: string;
constructor(private configService: ConfigService) {
this.authUrl = this.configService.get<string>('auth.url') || 'http://localhost:3001';
}
async login(
matrixUserId: string,
email: string,
password: string
): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(`${this.authUrl}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
success: false,
error: errorData.message || 'Authentifizierung fehlgeschlagen',
};
}
const data = await response.json();
const token = data.accessToken || data.token;
if (!token) {
return { success: false, error: 'Kein Token erhalten' };
}
// Store session (7 days expiry)
this.sessions.set(matrixUserId, {
token,
email,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
this.logger.log(`User ${matrixUserId} logged in as ${email}`);
return { success: true };
} catch (error) {
this.logger.error(`Login failed for ${matrixUserId}:`, error);
return {
success: false,
error: 'Verbindung zum Auth-Server fehlgeschlagen',
};
}
}
logout(matrixUserId: string): void {
this.sessions.delete(matrixUserId);
this.logger.log(`User ${matrixUserId} logged out`);
}
getToken(matrixUserId: string): string | null {
const session = this.sessions.get(matrixUserId);
if (!session) return null;
if (session.expiresAt < new Date()) {
this.sessions.delete(matrixUserId);
return null;
}
return session.token;
}
isLoggedIn(matrixUserId: string): boolean {
return this.getToken(matrixUserId) !== null;
}
getSessionCount(): number {
return this.sessions.size;
}
}

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
}
}