mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
feat(matrix-chat-bot): add Matrix bot for AI chat conversations
- Quick chat mode for stateless single messages (!chat) - Full conversation management (create, list, select, delete) - Message history with context-aware AI responses - Model selection (Ollama, OpenRouter, OpenAI, Anthropic) - Conversation actions: archive, restore, pin, unpin, rename - German/English command aliases - Number-based reference system for ease of use - JWT auth via mana-core-auth - Health check endpoint on port 3327 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
82da95b855
commit
68219a01df
17 changed files with 1591 additions and 0 deletions
15
services/matrix-chat-bot/.env.example
Normal file
15
services/matrix-chat-bot/.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Server
|
||||
PORT=3327
|
||||
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||
MATRIX_ACCESS_TOKEN=syt_xxx
|
||||
MATRIX_ALLOWED_ROOMS=#chat:matrix.mana.how
|
||||
MATRIX_STORAGE_PATH=./data/bot-storage.json
|
||||
|
||||
# Chat Backend
|
||||
CHAT_BACKEND_URL=http://localhost:3002
|
||||
CHAT_API_PREFIX=
|
||||
|
||||
# Mana Core Auth
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
5
services/matrix-chat-bot/.gitignore
vendored
Normal file
5
services/matrix-chat-bot/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
data/
|
||||
*.log
|
||||
226
services/matrix-chat-bot/CLAUDE.md
Normal file
226
services/matrix-chat-bot/CLAUDE.md
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
# Matrix Chat Bot - Claude Code Guidelines
|
||||
|
||||
## Overview
|
||||
|
||||
Matrix Chat Bot provides AI chat capabilities via Matrix chat. It integrates with the Chat backend for conversations, AI completions, and message history.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: NestJS 10
|
||||
- **Matrix**: matrix-bot-sdk
|
||||
- **Backend**: Chat API (port 3002)
|
||||
- **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-chat-bot/
|
||||
├── src/
|
||||
│ ├── main.ts # Application entry point (port 3327)
|
||||
│ ├── 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
|
||||
│ ├── chat/
|
||||
│ │ ├── chat.module.ts
|
||||
│ │ └── chat.service.ts # Chat Backend API client
|
||||
│ └── session/
|
||||
│ ├── session.module.ts
|
||||
│ └── session.service.ts # User session & auth management
|
||||
├── Dockerfile
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Bot Commands
|
||||
|
||||
| Command | Aliases | Description |
|
||||
|---------|---------|-------------|
|
||||
| `!help` | hilfe | Show help message |
|
||||
| `!login email pass` | - | Login |
|
||||
| `!logout` | - | Logout |
|
||||
| `!status` | - | Bot status |
|
||||
|
||||
### Quick Chat (Stateless)
|
||||
|
||||
| Command | Aliases | Description |
|
||||
|---------|---------|-------------|
|
||||
| `!chat [message]` | fragen, ask | Quick AI response (no history) |
|
||||
|
||||
### Conversation Management
|
||||
|
||||
| Command | Aliases | Description |
|
||||
|---------|---------|-------------|
|
||||
| `!neu [titel]` | new | Create new conversation |
|
||||
| `!gespraeche` | conversations, liste | List all conversations |
|
||||
| `!gespraech [nr]` | conversation, select | Select/view conversation |
|
||||
| `!senden [message]` | send, s | Send message in current conversation |
|
||||
| `!verlauf` | history, nachrichten | Show message history |
|
||||
|
||||
### Conversation Actions
|
||||
|
||||
| Command | Aliases | Description |
|
||||
|---------|---------|-------------|
|
||||
| `!titel [nr] [title]` | title | Change conversation title |
|
||||
| `!archiv [nr]` | archive | Archive conversation |
|
||||
| `!archiviert` | archived | List archived conversations |
|
||||
| `!wiederherstellen [nr]` | restore, unarchive | Restore from archive |
|
||||
| `!pin [nr]` | - | Pin conversation |
|
||||
| `!unpin [nr]` | - | Unpin conversation |
|
||||
| `!loeschen [nr]` | delete | Delete conversation |
|
||||
|
||||
### Model Selection
|
||||
|
||||
| Command | Aliases | Description |
|
||||
|---------|---------|-------------|
|
||||
| `!modelle` | models | List available AI models |
|
||||
| `!modell [nr]` | model | Select model for new conversations |
|
||||
|
||||
## Model Providers
|
||||
|
||||
| Provider | Icon | Description |
|
||||
|----------|------|-------------|
|
||||
| `ollama` | 🏠 | Local models (self-hosted) |
|
||||
| `openrouter` | ☁️ | Cloud models via OpenRouter |
|
||||
| `openai` | 🤖 | OpenAI models |
|
||||
| `anthropic` | 🧠 | Anthropic Claude models |
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
# Login
|
||||
!login max@example.com mypassword
|
||||
|
||||
# Quick chat (no conversation needed)
|
||||
!chat Was ist die Hauptstadt von Frankreich?
|
||||
|
||||
# Create a conversation
|
||||
!neu Programmierung Hilfe
|
||||
|
||||
# Send message in conversation
|
||||
!senden Erklaere mir Python Listen
|
||||
|
||||
# View message history
|
||||
!verlauf
|
||||
|
||||
# List conversations
|
||||
!gespraeche
|
||||
|
||||
# Select conversation
|
||||
!gespraech 1
|
||||
|
||||
# Change model
|
||||
!modelle
|
||||
!modell 2
|
||||
|
||||
# Archive and restore
|
||||
!archiv 1
|
||||
!archiviert
|
||||
!wiederherstellen 1
|
||||
|
||||
# Pin conversation
|
||||
!pin 1
|
||||
!unpin 1
|
||||
|
||||
# Delete conversation
|
||||
!loeschen 1
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# Server
|
||||
PORT=3327
|
||||
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||
MATRIX_ACCESS_TOKEN=syt_xxx
|
||||
MATRIX_ALLOWED_ROOMS=#chat:matrix.mana.how
|
||||
MATRIX_STORAGE_PATH=./data/bot-storage.json
|
||||
|
||||
# Chat Backend
|
||||
CHAT_BACKEND_URL=http://localhost:3002
|
||||
CHAT_API_PREFIX=
|
||||
|
||||
# Mana Core Auth
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Build locally
|
||||
docker build -f services/matrix-chat-bot/Dockerfile -t matrix-chat-bot services/matrix-chat-bot
|
||||
|
||||
# Run
|
||||
docker run -p 3327:3327 \
|
||||
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
|
||||
-e MATRIX_ACCESS_TOKEN=syt_xxx \
|
||||
-e CHAT_BACKEND_URL=http://chat-backend:3002 \
|
||||
-e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \
|
||||
-v matrix-chat-bot-data:/app/data \
|
||||
matrix-chat-bot
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:3327/health
|
||||
```
|
||||
|
||||
## Chat Backend API Endpoints Used
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/health` | GET | Health check |
|
||||
| `/models` | GET | List AI models (public) |
|
||||
| `/models/:id` | GET | Get model details (public) |
|
||||
| `/chat/completions` | POST | Create AI completion |
|
||||
| `/conversations` | GET | List conversations |
|
||||
| `/conversations` | POST | Create conversation |
|
||||
| `/conversations/archived` | GET | List archived |
|
||||
| `/conversations/:id` | GET | Get conversation |
|
||||
| `/conversations/:id` | DELETE | Delete conversation |
|
||||
| `/conversations/:id/messages` | GET | Get messages |
|
||||
| `/conversations/:id/messages` | POST | Add message |
|
||||
| `/conversations/:id/title` | PATCH | Update title |
|
||||
| `/conversations/:id/archive` | PATCH | Archive |
|
||||
| `/conversations/:id/unarchive` | PATCH | Unarchive |
|
||||
| `/conversations/:id/pin` | PATCH | Pin |
|
||||
| `/conversations/:id/unpin` | PATCH | Unpin |
|
||||
|
||||
## Chat Modes
|
||||
|
||||
The bot supports different ways to chat:
|
||||
|
||||
1. **Quick Chat** (`!chat`): Stateless, single message/response, no history
|
||||
2. **Conversation Chat** (`!senden`): Stateful, maintains message history, context-aware
|
||||
|
||||
## Number-Based Reference System
|
||||
|
||||
The bot uses a number-based reference system for ease of use:
|
||||
1. User runs `!gespraeche` or `!modelle` to get a list
|
||||
2. Bot stores the list internally for the user
|
||||
3. User can reference items by their list number
|
||||
4. Numbers are valid until the user runs a new list command
|
||||
|
||||
This allows simple commands like:
|
||||
- `!gespraech 3` - Select conversation #3
|
||||
- `!archiv 1` - Archive conversation #1
|
||||
- `!modell 2` - Select model #2
|
||||
41
services/matrix-chat-bot/Dockerfile
Normal file
41
services/matrix-chat-bot/Dockerfile
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install production dependencies only
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copy built application from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3327
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3327/health || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "dist/main.js"]
|
||||
8
services/matrix-chat-bot/nest-cli.json
Normal file
8
services/matrix-chat-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
27
services/matrix-chat-bot/package.json
Normal file
27
services/matrix-chat-bot/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "@mana-bot/matrix-chat",
|
||||
"version": "1.0.0",
|
||||
"description": "Matrix bot for AI chat conversations",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main.js",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"matrix-bot-sdk": "^0.7.1",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
17
services/matrix-chat-bot/src/app.module.ts
Normal file
17
services/matrix-chat-bot/src/app.module.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { BotModule } from './bot/bot.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import configuration from './config/configuration';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
}),
|
||||
BotModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
11
services/matrix-chat-bot/src/bot/bot.module.ts
Normal file
11
services/matrix-chat-bot/src/bot/bot.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MatrixService } from './matrix.service';
|
||||
import { ChatModule } from '../chat/chat.module';
|
||||
import { SessionModule } from '../session/session.module';
|
||||
|
||||
@Module({
|
||||
imports: [ChatModule, SessionModule],
|
||||
providers: [MatrixService],
|
||||
exports: [MatrixService],
|
||||
})
|
||||
export class BotModule {}
|
||||
754
services/matrix-chat-bot/src/bot/matrix.service.ts
Normal file
754
services/matrix-chat-bot/src/bot/matrix.service.ts
Normal file
|
|
@ -0,0 +1,754 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
MatrixClient,
|
||||
SimpleFsStorageProvider,
|
||||
AutojoinRoomsMixin,
|
||||
RichReply,
|
||||
} from 'matrix-bot-sdk';
|
||||
import { ChatService, Model, Conversation, Message } from '../chat/chat.service';
|
||||
import { SessionService } from '../session/session.service';
|
||||
import { HELP_MESSAGE, BRANCH_ICONS } from '../config/configuration';
|
||||
|
||||
@Injectable()
|
||||
export class MatrixService implements OnModuleInit {
|
||||
private readonly logger = new Logger(MatrixService.name);
|
||||
private client: MatrixClient;
|
||||
private allowedRooms: string[];
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private chatService: ChatService,
|
||||
private sessionService: SessionService
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
|
||||
const accessToken = this.configService.get<string>('matrix.accessToken');
|
||||
const storagePath = this.configService.get<string>('matrix.storagePath');
|
||||
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms') || [];
|
||||
|
||||
if (!accessToken) {
|
||||
this.logger.warn('No Matrix access token configured, bot disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = new SimpleFsStorageProvider(storagePath);
|
||||
this.client = new MatrixClient(homeserverUrl, accessToken, storage);
|
||||
AutojoinRoomsMixin.setupOnClient(this.client);
|
||||
|
||||
this.client.on('room.message', this.handleMessage.bind(this));
|
||||
|
||||
await this.client.start();
|
||||
this.logger.log('Matrix Chat Bot started');
|
||||
}
|
||||
|
||||
private async handleMessage(roomId: string, event: any) {
|
||||
if (event.sender === (await this.client.getUserId())) return;
|
||||
if (event.content?.msgtype !== 'm.text') return;
|
||||
|
||||
const body = event.content.body?.trim();
|
||||
if (!body?.startsWith('!')) return;
|
||||
|
||||
if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = event.sender;
|
||||
const [command, ...args] = body.slice(1).split(/\s+/);
|
||||
const argString = args.join(' ');
|
||||
|
||||
try {
|
||||
let response: string;
|
||||
|
||||
switch (command.toLowerCase()) {
|
||||
case 'help':
|
||||
case 'hilfe':
|
||||
response = HELP_MESSAGE;
|
||||
break;
|
||||
|
||||
case 'login':
|
||||
response = await this.handleLogin(sender, args);
|
||||
break;
|
||||
|
||||
case 'logout':
|
||||
response = this.handleLogout(sender);
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
response = this.handleStatus(sender);
|
||||
break;
|
||||
|
||||
case 'chat':
|
||||
case 'fragen':
|
||||
case 'ask':
|
||||
response = await this.handleQuickChat(sender, argString);
|
||||
break;
|
||||
|
||||
case 'neu':
|
||||
case 'new':
|
||||
response = await this.handleNewConversation(sender, argString);
|
||||
break;
|
||||
|
||||
case 'gespraeche':
|
||||
case 'gespräche':
|
||||
case 'conversations':
|
||||
case 'liste':
|
||||
response = await this.handleListConversations(sender);
|
||||
break;
|
||||
|
||||
case 'gespraech':
|
||||
case 'gespräch':
|
||||
case 'conversation':
|
||||
case 'select':
|
||||
response = await this.handleSelectConversation(sender, args[0]);
|
||||
break;
|
||||
|
||||
case 'senden':
|
||||
case 'send':
|
||||
case 's':
|
||||
response = await this.handleSendMessage(sender, argString);
|
||||
break;
|
||||
|
||||
case 'verlauf':
|
||||
case 'history':
|
||||
case 'nachrichten':
|
||||
response = await this.handleShowHistory(sender, args[0]);
|
||||
break;
|
||||
|
||||
case 'titel':
|
||||
case 'title':
|
||||
response = await this.handleUpdateTitle(sender, args[0], args.slice(1).join(' '));
|
||||
break;
|
||||
|
||||
case 'archiv':
|
||||
case 'archive':
|
||||
response = await this.handleArchive(sender, args[0]);
|
||||
break;
|
||||
|
||||
case 'archiviert':
|
||||
case 'archived':
|
||||
response = await this.handleListArchived(sender);
|
||||
break;
|
||||
|
||||
case 'wiederherstellen':
|
||||
case 'restore':
|
||||
case 'unarchive':
|
||||
response = await this.handleUnarchive(sender, args[0]);
|
||||
break;
|
||||
|
||||
case 'pin':
|
||||
response = await this.handlePin(sender, args[0]);
|
||||
break;
|
||||
|
||||
case 'unpin':
|
||||
response = await this.handleUnpin(sender, args[0]);
|
||||
break;
|
||||
|
||||
case 'loeschen':
|
||||
case 'löschen':
|
||||
case 'delete':
|
||||
response = await this.handleDelete(sender, args[0]);
|
||||
break;
|
||||
|
||||
case 'modelle':
|
||||
case 'models':
|
||||
response = await this.handleListModels(sender);
|
||||
break;
|
||||
|
||||
case 'modell':
|
||||
case 'model':
|
||||
response = await this.handleSelectModel(sender, args[0]);
|
||||
break;
|
||||
|
||||
default:
|
||||
response = `Unbekannter Befehl: ${command}\nNutze \`!help\` fuer eine Uebersicht.`;
|
||||
}
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error handling command ${command}:`, error);
|
||||
await this.sendReply(roomId, event, 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.');
|
||||
}
|
||||
}
|
||||
|
||||
private async sendReply(roomId: string, event: any, message: string) {
|
||||
const reply = RichReply.createFor(roomId, event, message, message);
|
||||
reply.msgtype = 'm.text';
|
||||
await this.client.sendMessage(roomId, reply);
|
||||
}
|
||||
|
||||
// Auth handlers
|
||||
private async handleLogin(sender: string, args: string[]): Promise<string> {
|
||||
if (args.length < 2) {
|
||||
return 'Verwendung: `!login email passwort`';
|
||||
}
|
||||
|
||||
const [email, password] = args;
|
||||
const result = await this.sessionService.login(sender, email, password);
|
||||
|
||||
if (result.success) {
|
||||
return `Erfolgreich angemeldet als **${email}**`;
|
||||
}
|
||||
return `Anmeldung fehlgeschlagen: ${result.error}`;
|
||||
}
|
||||
|
||||
private handleLogout(sender: string): string {
|
||||
this.sessionService.logout(sender);
|
||||
return 'Erfolgreich abgemeldet.';
|
||||
}
|
||||
|
||||
private handleStatus(sender: string): string {
|
||||
const isLoggedIn = this.sessionService.isLoggedIn(sender);
|
||||
const currentConv = this.sessionService.getCurrentConversation(sender);
|
||||
const selectedModel = this.sessionService.getSelectedModel(sender);
|
||||
|
||||
let status = `**Bot Status**\n`;
|
||||
status += `- Angemeldet: ${isLoggedIn ? 'Ja' : 'Nein'}\n`;
|
||||
if (currentConv) {
|
||||
status += `- Aktuelles Gespraech: ${currentConv.substring(0, 8)}...\n`;
|
||||
}
|
||||
if (selectedModel) {
|
||||
status += `- Gewaehltes Modell: ${selectedModel.substring(0, 8)}...\n`;
|
||||
}
|
||||
status += `- Aktive Sessions: ${this.sessionService.getSessionCount()}`;
|
||||
return status;
|
||||
}
|
||||
|
||||
// Quick chat (stateless)
|
||||
private async handleQuickChat(sender: string, message: string): Promise<string> {
|
||||
if (!message) {
|
||||
return 'Verwendung: `!chat [deine nachricht]`';
|
||||
}
|
||||
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
return 'Bitte zuerst anmelden mit `!login email passwort`';
|
||||
}
|
||||
|
||||
// Get models to find default
|
||||
const modelsResult = await this.chatService.getModels();
|
||||
if (modelsResult.error || !modelsResult.data?.length) {
|
||||
return 'Keine AI-Modelle verfuegbar.';
|
||||
}
|
||||
|
||||
const selectedModelId = this.sessionService.getSelectedModel(sender);
|
||||
const modelId = selectedModelId || modelsResult.data.find((m) => m.isDefault)?.id || modelsResult.data[0].id;
|
||||
|
||||
const result = await this.chatService.createCompletion(
|
||||
token,
|
||||
[{ role: 'user', content: message }],
|
||||
modelId
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
let response = result.data.content;
|
||||
if (result.data.usage) {
|
||||
response += `\n\n_Tokens: ${result.data.usage.total_tokens}_`;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// Conversation management
|
||||
private async handleNewConversation(sender: string, title: string): Promise<string> {
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
return 'Bitte zuerst anmelden mit `!login email passwort`';
|
||||
}
|
||||
|
||||
// Get models to find default
|
||||
const modelsResult = await this.chatService.getModels();
|
||||
if (modelsResult.error || !modelsResult.data?.length) {
|
||||
return 'Keine AI-Modelle verfuegbar.';
|
||||
}
|
||||
|
||||
const selectedModelId = this.sessionService.getSelectedModel(sender);
|
||||
const modelId = selectedModelId || modelsResult.data.find((m) => m.isDefault)?.id || modelsResult.data[0].id;
|
||||
|
||||
const convTitle = title || `Matrix Chat ${new Date().toLocaleDateString('de-DE')}`;
|
||||
const result = await this.chatService.createConversation(token, {
|
||||
title: convTitle,
|
||||
modelId,
|
||||
conversationMode: 'free',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
this.sessionService.setCurrentConversation(sender, result.data.id);
|
||||
return `Neues Gespraech erstellt: **${result.data.title}**\nNutze \`!senden [nachricht]\` um zu chatten.`;
|
||||
}
|
||||
|
||||
private async handleListConversations(sender: string): Promise<string> {
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
return 'Bitte zuerst anmelden mit `!login email passwort`';
|
||||
}
|
||||
|
||||
const result = await this.chatService.getConversations(token);
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
if (!result.data?.length) {
|
||||
return 'Keine Gespraeche vorhanden. Erstelle eines mit `!neu [titel]`';
|
||||
}
|
||||
|
||||
// Sort: pinned first, then by date
|
||||
const sorted = result.data.sort((a, b) => {
|
||||
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
|
||||
// Store mapping
|
||||
this.sessionService.setConversationMapping(
|
||||
sender,
|
||||
sorted.map((c) => c.id)
|
||||
);
|
||||
|
||||
const currentId = this.sessionService.getCurrentConversation(sender);
|
||||
|
||||
let response = '**Deine Gespraeche:**\n\n';
|
||||
sorted.forEach((conv, index) => {
|
||||
const pin = conv.isPinned ? '📌 ' : '';
|
||||
const current = conv.id === currentId ? ' ◀️' : '';
|
||||
const date = new Date(conv.updatedAt).toLocaleDateString('de-DE');
|
||||
response += `${index + 1}. ${pin}**${conv.title}**${current}\n _${date}_\n`;
|
||||
});
|
||||
|
||||
response += '\nNutze `!gespraech [nr]` zum Auswaehlen.';
|
||||
return response;
|
||||
}
|
||||
|
||||
private async handleSelectConversation(sender: string, numberStr: string): Promise<string> {
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
return 'Bitte zuerst anmelden mit `!login email passwort`';
|
||||
}
|
||||
|
||||
if (!numberStr) {
|
||||
// Show current conversation
|
||||
const currentId = this.sessionService.getCurrentConversation(sender);
|
||||
if (!currentId) {
|
||||
return 'Kein Gespraech ausgewaehlt. Nutze `!gespraeche` und dann `!gespraech [nr]`';
|
||||
}
|
||||
|
||||
const result = await this.chatService.getConversation(token, currentId);
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
return this.formatConversationDetails(result.data);
|
||||
}
|
||||
|
||||
const number = parseInt(numberStr, 10);
|
||||
if (isNaN(number)) {
|
||||
return 'Bitte eine gueltige Nummer angeben.';
|
||||
}
|
||||
|
||||
const conversationId = this.sessionService.getConversationId(sender, number);
|
||||
if (!conversationId) {
|
||||
return 'Ungueltige Nummer. Nutze `!gespraeche` fuer eine aktuelle Liste.';
|
||||
}
|
||||
|
||||
const result = await this.chatService.getConversation(token, conversationId);
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
this.sessionService.setCurrentConversation(sender, conversationId);
|
||||
return `Gespraech ausgewaehlt: **${result.data.title}**\n\n${this.formatConversationDetails(result.data)}`;
|
||||
}
|
||||
|
||||
private formatConversationDetails(conv: Conversation): string {
|
||||
const pin = conv.isPinned ? '📌 Angepinnt' : '';
|
||||
const created = new Date(conv.createdAt).toLocaleDateString('de-DE');
|
||||
const updated = new Date(conv.updatedAt).toLocaleDateString('de-DE');
|
||||
|
||||
return `**${conv.title}** ${pin}
|
||||
- Modus: ${conv.conversationMode}
|
||||
- Dokument-Modus: ${conv.documentMode ? 'Ja' : 'Nein'}
|
||||
- Erstellt: ${created}
|
||||
- Aktualisiert: ${updated}
|
||||
|
||||
Nutze \`!senden [nachricht]\` um zu chatten oder \`!verlauf\` fuer den Nachrichtenverlauf.`;
|
||||
}
|
||||
|
||||
private async handleSendMessage(sender: string, message: string): Promise<string> {
|
||||
if (!message) {
|
||||
return 'Verwendung: `!senden [deine nachricht]`';
|
||||
}
|
||||
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
return 'Bitte zuerst anmelden mit `!login email passwort`';
|
||||
}
|
||||
|
||||
const conversationId = this.sessionService.getCurrentConversation(sender);
|
||||
if (!conversationId) {
|
||||
return 'Kein Gespraech ausgewaehlt. Nutze `!gespraeche` und `!gespraech [nr]` oder `!neu [titel]`';
|
||||
}
|
||||
|
||||
// Add user message
|
||||
const userMsgResult = await this.chatService.addMessage(token, conversationId, message, 'user');
|
||||
if (userMsgResult.error) {
|
||||
return `Fehler: ${userMsgResult.error}`;
|
||||
}
|
||||
|
||||
// Get conversation for model ID
|
||||
const convResult = await this.chatService.getConversation(token, conversationId);
|
||||
if (convResult.error) {
|
||||
return `Fehler: ${convResult.error}`;
|
||||
}
|
||||
|
||||
// Get message history for context
|
||||
const historyResult = await this.chatService.getMessages(token, conversationId);
|
||||
const messages = (historyResult.data || []).map((m) => ({
|
||||
role: m.sender as 'user' | 'assistant' | 'system',
|
||||
content: m.messageText,
|
||||
}));
|
||||
|
||||
// Get AI response
|
||||
const completionResult = await this.chatService.createCompletion(token, messages, convResult.data.modelId);
|
||||
if (completionResult.error) {
|
||||
return `Fehler bei AI-Antwort: ${completionResult.error}`;
|
||||
}
|
||||
|
||||
// Save assistant response
|
||||
await this.chatService.addMessage(token, conversationId, completionResult.data.content, 'assistant');
|
||||
|
||||
let response = completionResult.data.content;
|
||||
if (completionResult.data.usage) {
|
||||
response += `\n\n_Tokens: ${completionResult.data.usage.total_tokens}_`;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private async handleShowHistory(sender: string, numberStr?: string): Promise<string> {
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
return 'Bitte zuerst anmelden mit `!login email passwort`';
|
||||
}
|
||||
|
||||
let conversationId = this.sessionService.getCurrentConversation(sender);
|
||||
|
||||
if (numberStr) {
|
||||
const number = parseInt(numberStr, 10);
|
||||
if (!isNaN(number)) {
|
||||
const id = this.sessionService.getConversationId(sender, number);
|
||||
if (id) conversationId = id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!conversationId) {
|
||||
return 'Kein Gespraech ausgewaehlt. Nutze `!gespraeche` und `!gespraech [nr]`';
|
||||
}
|
||||
|
||||
const result = await this.chatService.getMessages(token, conversationId);
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
if (!result.data?.length) {
|
||||
return 'Noch keine Nachrichten in diesem Gespraech.';
|
||||
}
|
||||
|
||||
let response = '**Nachrichtenverlauf:**\n\n';
|
||||
const recentMessages = result.data.slice(-10); // Last 10 messages
|
||||
|
||||
recentMessages.forEach((msg) => {
|
||||
const icon = msg.sender === 'user' ? '👤' : msg.sender === 'assistant' ? '🤖' : '⚙️';
|
||||
const text = msg.messageText.length > 200 ? msg.messageText.substring(0, 200) + '...' : msg.messageText;
|
||||
response += `${icon} **${msg.sender}:**\n${text}\n\n`;
|
||||
});
|
||||
|
||||
if (result.data.length > 10) {
|
||||
response += `_...und ${result.data.length - 10} weitere Nachrichten_`;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Conversation management actions
|
||||
private async handleUpdateTitle(sender: string, numberStr: string, title: string): Promise<string> {
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
return 'Bitte zuerst anmelden mit `!login email passwort`';
|
||||
}
|
||||
|
||||
if (!numberStr || !title) {
|
||||
return 'Verwendung: `!titel [nr] [neuer titel]`';
|
||||
}
|
||||
|
||||
const number = parseInt(numberStr, 10);
|
||||
if (isNaN(number)) {
|
||||
return 'Bitte eine gueltige Nummer angeben.';
|
||||
}
|
||||
|
||||
const conversationId = this.sessionService.getConversationId(sender, number);
|
||||
if (!conversationId) {
|
||||
return 'Ungueltige Nummer. Nutze `!gespraeche` fuer eine aktuelle Liste.';
|
||||
}
|
||||
|
||||
const result = await this.chatService.updateTitle(token, conversationId, title);
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
return `Titel geaendert zu: **${result.data.title}**`;
|
||||
}
|
||||
|
||||
private async handleArchive(sender: string, numberStr: string): Promise<string> {
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
return 'Bitte zuerst anmelden mit `!login email passwort`';
|
||||
}
|
||||
|
||||
if (!numberStr) {
|
||||
return 'Verwendung: `!archiv [nr]`';
|
||||
}
|
||||
|
||||
const number = parseInt(numberStr, 10);
|
||||
if (isNaN(number)) {
|
||||
return 'Bitte eine gueltige Nummer angeben.';
|
||||
}
|
||||
|
||||
const conversationId = this.sessionService.getConversationId(sender, number);
|
||||
if (!conversationId) {
|
||||
return 'Ungueltige Nummer. Nutze `!gespraeche` fuer eine aktuelle Liste.';
|
||||
}
|
||||
|
||||
const result = await this.chatService.archiveConversation(token, conversationId);
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
return `Gespraech **${result.data.title}** archiviert.`;
|
||||
}
|
||||
|
||||
private async handleListArchived(sender: string): Promise<string> {
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
return 'Bitte zuerst anmelden mit `!login email passwort`';
|
||||
}
|
||||
|
||||
const result = await this.chatService.getArchivedConversations(token);
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
if (!result.data?.length) {
|
||||
return 'Keine archivierten Gespraeche.';
|
||||
}
|
||||
|
||||
// Store mapping for restore
|
||||
this.sessionService.setConversationMapping(
|
||||
sender,
|
||||
result.data.map((c) => c.id)
|
||||
);
|
||||
|
||||
let response = '**Archivierte Gespraeche:**\n\n';
|
||||
result.data.forEach((conv, index) => {
|
||||
const date = new Date(conv.updatedAt).toLocaleDateString('de-DE');
|
||||
response += `${index + 1}. **${conv.title}**\n _${date}_\n`;
|
||||
});
|
||||
|
||||
response += '\nNutze `!wiederherstellen [nr]` zum Wiederherstellen.';
|
||||
return response;
|
||||
}
|
||||
|
||||
private async handleUnarchive(sender: string, numberStr: string): Promise<string> {
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
return 'Bitte zuerst anmelden mit `!login email passwort`';
|
||||
}
|
||||
|
||||
if (!numberStr) {
|
||||
return 'Verwendung: `!wiederherstellen [nr]`';
|
||||
}
|
||||
|
||||
const number = parseInt(numberStr, 10);
|
||||
if (isNaN(number)) {
|
||||
return 'Bitte eine gueltige Nummer angeben.';
|
||||
}
|
||||
|
||||
const conversationId = this.sessionService.getConversationId(sender, number);
|
||||
if (!conversationId) {
|
||||
return 'Ungueltige Nummer. Nutze `!archiviert` fuer eine aktuelle Liste.';
|
||||
}
|
||||
|
||||
const result = await this.chatService.unarchiveConversation(token, conversationId);
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
return `Gespraech **${result.data.title}** wiederhergestellt.`;
|
||||
}
|
||||
|
||||
private async handlePin(sender: string, numberStr: string): Promise<string> {
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
return 'Bitte zuerst anmelden mit `!login email passwort`';
|
||||
}
|
||||
|
||||
if (!numberStr) {
|
||||
return 'Verwendung: `!pin [nr]`';
|
||||
}
|
||||
|
||||
const number = parseInt(numberStr, 10);
|
||||
if (isNaN(number)) {
|
||||
return 'Bitte eine gueltige Nummer angeben.';
|
||||
}
|
||||
|
||||
const conversationId = this.sessionService.getConversationId(sender, number);
|
||||
if (!conversationId) {
|
||||
return 'Ungueltige Nummer. Nutze `!gespraeche` fuer eine aktuelle Liste.';
|
||||
}
|
||||
|
||||
const result = await this.chatService.pinConversation(token, conversationId);
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
return `Gespraech **${result.data.title}** angepinnt. 📌`;
|
||||
}
|
||||
|
||||
private async handleUnpin(sender: string, numberStr: string): Promise<string> {
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
return 'Bitte zuerst anmelden mit `!login email passwort`';
|
||||
}
|
||||
|
||||
if (!numberStr) {
|
||||
return 'Verwendung: `!unpin [nr]`';
|
||||
}
|
||||
|
||||
const number = parseInt(numberStr, 10);
|
||||
if (isNaN(number)) {
|
||||
return 'Bitte eine gueltige Nummer angeben.';
|
||||
}
|
||||
|
||||
const conversationId = this.sessionService.getConversationId(sender, number);
|
||||
if (!conversationId) {
|
||||
return 'Ungueltige Nummer. Nutze `!gespraeche` fuer eine aktuelle Liste.';
|
||||
}
|
||||
|
||||
const result = await this.chatService.unpinConversation(token, conversationId);
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
return `Pin fuer **${result.data.title}** entfernt.`;
|
||||
}
|
||||
|
||||
private async handleDelete(sender: string, numberStr: string): Promise<string> {
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
return 'Bitte zuerst anmelden mit `!login email passwort`';
|
||||
}
|
||||
|
||||
if (!numberStr) {
|
||||
return 'Verwendung: `!loeschen [nr]`';
|
||||
}
|
||||
|
||||
const number = parseInt(numberStr, 10);
|
||||
if (isNaN(number)) {
|
||||
return 'Bitte eine gueltige Nummer angeben.';
|
||||
}
|
||||
|
||||
const conversationId = this.sessionService.getConversationId(sender, number);
|
||||
if (!conversationId) {
|
||||
return 'Ungueltige Nummer. Nutze `!gespraeche` fuer eine aktuelle Liste.';
|
||||
}
|
||||
|
||||
// Get title before deletion
|
||||
const convResult = await this.chatService.getConversation(token, conversationId);
|
||||
const title = convResult.data?.title || 'Gespraech';
|
||||
|
||||
const result = await this.chatService.deleteConversation(token, conversationId);
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
// Clear current conversation if it was the deleted one
|
||||
if (this.sessionService.getCurrentConversation(sender) === conversationId) {
|
||||
this.sessionService.setCurrentConversation(sender, null);
|
||||
}
|
||||
|
||||
return `Gespraech **${title}** geloescht.`;
|
||||
}
|
||||
|
||||
// Model management
|
||||
private async handleListModels(sender: string): Promise<string> {
|
||||
const result = await this.chatService.getModels();
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
if (!result.data?.length) {
|
||||
return 'Keine AI-Modelle verfuegbar.';
|
||||
}
|
||||
|
||||
const activeModels = result.data.filter((m) => m.isActive);
|
||||
|
||||
// Store mapping
|
||||
this.sessionService.setModelMapping(
|
||||
sender,
|
||||
activeModels.map((m) => m.id)
|
||||
);
|
||||
|
||||
const selectedModelId = this.sessionService.getSelectedModel(sender);
|
||||
|
||||
let response = '**Verfuegbare AI-Modelle:**\n\n';
|
||||
activeModels.forEach((model, index) => {
|
||||
const icon = BRANCH_ICONS[model.provider] || BRANCH_ICONS.default;
|
||||
const isDefault = model.isDefault ? ' (Standard)' : '';
|
||||
const selected = model.id === selectedModelId ? ' ◀️' : '';
|
||||
const desc = model.description ? `\n _${model.description}_` : '';
|
||||
response += `${index + 1}. ${icon} **${model.name}**${isDefault}${selected}${desc}\n`;
|
||||
});
|
||||
|
||||
response += '\nNutze `!modell [nr]` zum Auswaehlen.';
|
||||
return response;
|
||||
}
|
||||
|
||||
private async handleSelectModel(sender: string, numberStr: string): Promise<string> {
|
||||
if (!numberStr) {
|
||||
const selectedModelId = this.sessionService.getSelectedModel(sender);
|
||||
if (!selectedModelId) {
|
||||
return 'Kein Modell ausgewaehlt (Standard wird verwendet). Nutze `!modelle` und `!modell [nr]`';
|
||||
}
|
||||
|
||||
const result = await this.chatService.getModel(selectedModelId);
|
||||
if (result.error) {
|
||||
return 'Ausgewaehltes Modell nicht gefunden.';
|
||||
}
|
||||
|
||||
const icon = BRANCH_ICONS[result.data.provider] || BRANCH_ICONS.default;
|
||||
return `Aktuelles Modell: ${icon} **${result.data.name}**`;
|
||||
}
|
||||
|
||||
const number = parseInt(numberStr, 10);
|
||||
if (isNaN(number)) {
|
||||
return 'Bitte eine gueltige Nummer angeben.';
|
||||
}
|
||||
|
||||
const modelId = this.sessionService.getModelId(sender, number);
|
||||
if (!modelId) {
|
||||
return 'Ungueltige Nummer. Nutze `!modelle` fuer eine aktuelle Liste.';
|
||||
}
|
||||
|
||||
const result = await this.chatService.getModel(modelId);
|
||||
if (result.error) {
|
||||
return `Fehler: ${result.error}`;
|
||||
}
|
||||
|
||||
this.sessionService.setSelectedModel(sender, modelId);
|
||||
const icon = BRANCH_ICONS[result.data.provider] || BRANCH_ICONS.default;
|
||||
return `Modell gewaehlt: ${icon} **${result.data.name}**\nWird fuer neue Gespraeche und Quick-Chat verwendet.`;
|
||||
}
|
||||
}
|
||||
8
services/matrix-chat-bot/src/chat/chat.module.ts
Normal file
8
services/matrix-chat-bot/src/chat/chat.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ChatService } from './chat.service';
|
||||
|
||||
@Module({
|
||||
providers: [ChatService],
|
||||
exports: [ChatService],
|
||||
})
|
||||
export class ChatModule {}
|
||||
220
services/matrix-chat-bot/src/chat/chat.service.ts
Normal file
220
services/matrix-chat-bot/src/chat/chat.service.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
provider: string;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
userId: string;
|
||||
modelId: string;
|
||||
title: string;
|
||||
conversationMode: 'free' | 'guided' | 'template';
|
||||
documentMode: boolean;
|
||||
isArchived: boolean;
|
||||
isPinned: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
sender: 'user' | 'assistant' | 'system';
|
||||
messageText: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ChatCompletionResponse {
|
||||
content: string;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
private readonly logger = new Logger(ChatService.name);
|
||||
private baseUrl: string;
|
||||
private apiPrefix: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.baseUrl = this.configService.get<string>('chat.url') || 'http://localhost:3002';
|
||||
this.apiPrefix = this.configService.get<string>('chat.apiPrefix') || '';
|
||||
}
|
||||
|
||||
private getUrl(path: string): string {
|
||||
return `${this.baseUrl}${this.apiPrefix}${path}`;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
token: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data?: T; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(this.getUrl(path), {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return { error: errorData.message || `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
this.logger.error(`Request failed: ${path}`, error);
|
||||
return { error: 'Verbindung zum Chat-Server fehlgeschlagen' };
|
||||
}
|
||||
}
|
||||
|
||||
// Models (public endpoints)
|
||||
async getModels(): Promise<{ data?: Model[]; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(this.getUrl('/models'));
|
||||
if (!response.ok) {
|
||||
return { error: `HTTP ${response.status}` };
|
||||
}
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to fetch models', error);
|
||||
return { error: 'Verbindung zum Chat-Server fehlgeschlagen' };
|
||||
}
|
||||
}
|
||||
|
||||
async getModel(id: string): Promise<{ data?: Model; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(this.getUrl(`/models/${id}`));
|
||||
if (!response.ok) {
|
||||
return { error: `HTTP ${response.status}` };
|
||||
}
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch model ${id}`, error);
|
||||
return { error: 'Modell nicht gefunden' };
|
||||
}
|
||||
}
|
||||
|
||||
// Chat Completions
|
||||
async createCompletion(
|
||||
token: string,
|
||||
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
|
||||
modelId: string,
|
||||
options?: { temperature?: number; maxTokens?: number }
|
||||
): Promise<{ data?: ChatCompletionResponse; error?: string }> {
|
||||
return this.request<ChatCompletionResponse>('/chat/completions', token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
modelId,
|
||||
temperature: options?.temperature,
|
||||
maxTokens: options?.maxTokens,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Conversations
|
||||
async getConversations(
|
||||
token: string,
|
||||
spaceId?: string
|
||||
): Promise<{ data?: Conversation[]; error?: string }> {
|
||||
const query = spaceId ? `?spaceId=${spaceId}` : '';
|
||||
return this.request<Conversation[]>(`/conversations${query}`, token);
|
||||
}
|
||||
|
||||
async getArchivedConversations(token: string): Promise<{ data?: Conversation[]; error?: string }> {
|
||||
return this.request<Conversation[]>('/conversations/archived', token);
|
||||
}
|
||||
|
||||
async getConversation(token: string, id: string): Promise<{ data?: Conversation; error?: string }> {
|
||||
return this.request<Conversation>(`/conversations/${id}`, token);
|
||||
}
|
||||
|
||||
async getMessages(token: string, conversationId: string): Promise<{ data?: Message[]; error?: string }> {
|
||||
return this.request<Message[]>(`/conversations/${conversationId}/messages`, token);
|
||||
}
|
||||
|
||||
async createConversation(
|
||||
token: string,
|
||||
data: {
|
||||
title: string;
|
||||
modelId: string;
|
||||
conversationMode?: 'free' | 'guided' | 'template';
|
||||
}
|
||||
): Promise<{ data?: Conversation; error?: string }> {
|
||||
return this.request<Conversation>('/conversations', token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async addMessage(
|
||||
token: string,
|
||||
conversationId: string,
|
||||
messageText: string,
|
||||
sender: 'user' | 'assistant' = 'user'
|
||||
): Promise<{ data?: Message; error?: string }> {
|
||||
return this.request<Message>(`/conversations/${conversationId}/messages`, token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ messageText, sender }),
|
||||
});
|
||||
}
|
||||
|
||||
async updateTitle(
|
||||
token: string,
|
||||
conversationId: string,
|
||||
title: string
|
||||
): Promise<{ data?: Conversation; error?: string }> {
|
||||
return this.request<Conversation>(`/conversations/${conversationId}/title`, token, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
}
|
||||
|
||||
async archiveConversation(token: string, conversationId: string): Promise<{ data?: Conversation; error?: string }> {
|
||||
return this.request<Conversation>(`/conversations/${conversationId}/archive`, token, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
}
|
||||
|
||||
async unarchiveConversation(token: string, conversationId: string): Promise<{ data?: Conversation; error?: string }> {
|
||||
return this.request<Conversation>(`/conversations/${conversationId}/unarchive`, token, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
}
|
||||
|
||||
async pinConversation(token: string, conversationId: string): Promise<{ data?: Conversation; error?: string }> {
|
||||
return this.request<Conversation>(`/conversations/${conversationId}/pin`, token, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
}
|
||||
|
||||
async unpinConversation(token: string, conversationId: string): Promise<{ data?: Conversation; error?: string }> {
|
||||
return this.request<Conversation>(`/conversations/${conversationId}/unpin`, token, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteConversation(token: string, conversationId: string): Promise<{ error?: string }> {
|
||||
return this.request(`/conversations/${conversationId}`, token, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
}
|
||||
68
services/matrix-chat-bot/src/config/configuration.ts
Normal file
68
services/matrix-chat-bot/src/config/configuration.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
export default () => ({
|
||||
port: parseInt(process.env.PORT, 10) || 3327,
|
||||
matrix: {
|
||||
homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008',
|
||||
accessToken: process.env.MATRIX_ACCESS_TOKEN,
|
||||
allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',') || [],
|
||||
storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json',
|
||||
},
|
||||
chat: {
|
||||
url: process.env.CHAT_BACKEND_URL || 'http://localhost:3002',
|
||||
apiPrefix: process.env.CHAT_API_PREFIX || '',
|
||||
},
|
||||
auth: {
|
||||
url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
|
||||
},
|
||||
});
|
||||
|
||||
export const HELP_MESSAGE = `**AI Chat Bot - Hilfe**
|
||||
|
||||
**Authentifizierung:**
|
||||
- \`!login email passwort\` - Anmelden
|
||||
- \`!logout\` - Abmelden
|
||||
- \`!status\` - Bot-Status anzeigen
|
||||
|
||||
**Schnell-Chat:**
|
||||
- \`!chat [nachricht]\` - Schnelle AI-Antwort (ohne Verlauf)
|
||||
- \`!fragen [nachricht]\` - Alias fuer !chat
|
||||
|
||||
**Gespraeche:**
|
||||
- \`!neu [titel]\` - Neues Gespraech starten
|
||||
- \`!gespraeche\` - Alle Gespraeche auflisten
|
||||
- \`!gespraech [nr]\` - Gespraech auswaehlen/anzeigen
|
||||
- \`!senden [nachricht]\` - Nachricht im aktuellen Gespraech senden
|
||||
- \`!verlauf\` - Nachrichtenverlauf anzeigen
|
||||
|
||||
**Gespraechsverwaltung:**
|
||||
- \`!titel [nr] [neuer titel]\` - Titel aendern
|
||||
- \`!archiv [nr]\` - Gespraech archivieren
|
||||
- \`!archiviert\` - Archivierte Gespraeche anzeigen
|
||||
- \`!wiederherstellen [nr]\` - Aus Archiv wiederherstellen
|
||||
- \`!pin [nr]\` - Gespraech anpinnen
|
||||
- \`!unpin [nr]\` - Pin entfernen
|
||||
- \`!loeschen [nr]\` - Gespraech loeschen
|
||||
|
||||
**Modelle:**
|
||||
- \`!modelle\` - Verfuegbare AI-Modelle auflisten
|
||||
- \`!modell [nr]\` - Modell fuer neues Gespraech waehlen
|
||||
|
||||
**Beispiele:**
|
||||
\`\`\`
|
||||
!login max@example.com meinpasswort
|
||||
!chat Was ist die Hauptstadt von Frankreich?
|
||||
!neu Programmierung
|
||||
!senden Erklaere mir Python Listen
|
||||
!gespraeche
|
||||
!gespraech 1
|
||||
!verlauf
|
||||
!modelle
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
export const BRANCH_ICONS: Record<string, string> = {
|
||||
ollama: '🏠',
|
||||
openrouter: '☁️',
|
||||
openai: '🤖',
|
||||
anthropic: '🧠',
|
||||
default: '🔮',
|
||||
};
|
||||
9
services/matrix-chat-bot/src/health.controller.ts
Normal file
9
services/matrix-chat-bot/src/health.controller.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok', service: 'matrix-chat-bot' };
|
||||
}
|
||||
}
|
||||
10
services/matrix-chat-bot/src/main.ts
Normal file
10
services/matrix-chat-bot/src/main.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const port = process.env.PORT || 3327;
|
||||
await app.listen(port);
|
||||
console.log(`Matrix Chat Bot running on port ${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
8
services/matrix-chat-bot/src/session/session.module.ts
Normal file
8
services/matrix-chat-bot/src/session/session.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Module({
|
||||
providers: [SessionService],
|
||||
exports: [SessionService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
142
services/matrix-chat-bot/src/session/session.service.ts
Normal file
142
services/matrix-chat-bot/src/session/session.service.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
interface UserSession {
|
||||
token: string;
|
||||
email: string;
|
||||
expiresAt: Date;
|
||||
currentConversationId?: string;
|
||||
selectedModelId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
private readonly logger = new Logger(SessionService.name);
|
||||
private sessions: Map<string, UserSession> = new Map();
|
||||
private authUrl: string;
|
||||
|
||||
// Store conversation list mappings per user
|
||||
private conversationMappings: Map<string, string[]> = new Map();
|
||||
private modelMappings: Map<string, string[]> = new Map();
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
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.conversationMappings.delete(matrixUserId);
|
||||
this.modelMappings.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;
|
||||
}
|
||||
|
||||
// Current conversation management
|
||||
setCurrentConversation(matrixUserId: string, conversationId: string): void {
|
||||
const session = this.sessions.get(matrixUserId);
|
||||
if (session) {
|
||||
session.currentConversationId = conversationId;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentConversation(matrixUserId: string): string | null {
|
||||
const session = this.sessions.get(matrixUserId);
|
||||
return session?.currentConversationId || null;
|
||||
}
|
||||
|
||||
// Selected model management
|
||||
setSelectedModel(matrixUserId: string, modelId: string): void {
|
||||
const session = this.sessions.get(matrixUserId);
|
||||
if (session) {
|
||||
session.selectedModelId = modelId;
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedModel(matrixUserId: string): string | null {
|
||||
const session = this.sessions.get(matrixUserId);
|
||||
return session?.selectedModelId || null;
|
||||
}
|
||||
|
||||
// Conversation number mapping
|
||||
setConversationMapping(matrixUserId: string, ids: string[]): void {
|
||||
this.conversationMappings.set(matrixUserId, ids);
|
||||
}
|
||||
|
||||
getConversationId(matrixUserId: string, number: number): string | null {
|
||||
const ids = this.conversationMappings.get(matrixUserId);
|
||||
if (!ids || number < 1 || number > ids.length) return null;
|
||||
return ids[number - 1];
|
||||
}
|
||||
|
||||
// Model number mapping
|
||||
setModelMapping(matrixUserId: string, ids: string[]): void {
|
||||
this.modelMappings.set(matrixUserId, ids);
|
||||
}
|
||||
|
||||
getModelId(matrixUserId: string, number: number): string | null {
|
||||
const ids = this.modelMappings.get(matrixUserId);
|
||||
if (!ids || number < 1 || number > ids.length) return null;
|
||||
return ids[number - 1];
|
||||
}
|
||||
}
|
||||
22
services/matrix-chat-bot/tsconfig.json
Normal file
22
services/matrix-chat-bot/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue