mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(matrix-questions-bot): add Matrix bot for Q&A research management
- Full NestJS bot with matrix-bot-sdk integration - Question management: create, list, view, delete, archive - Research: start quick/standard/deep research via mana-search - Results: view summaries, key points, follow-up questions - Sources: view ranked sources with relevance scores - Answers: view, rate (1-5), accept as solution - Collections: list and create for organization - Search: full-text search across questions - Status tracking: open, researching, answered, archived - Priority levels: low, normal, high, urgent - German/English command aliases - Number-based reference system - JWT auth via mana-core-auth - Runs on port 3324 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
8779d04789
commit
c5476447ec
17 changed files with 1563 additions and 0 deletions
15
services/matrix-questions-bot/.env.example
Normal file
15
services/matrix-questions-bot/.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Server
|
||||
PORT=3324
|
||||
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||
MATRIX_ACCESS_TOKEN=syt_xxx
|
||||
MATRIX_ALLOWED_ROOMS=#questions:matrix.mana.how
|
||||
MATRIX_STORAGE_PATH=./data/bot-storage.json
|
||||
|
||||
# Questions Backend
|
||||
QUESTIONS_BACKEND_URL=http://localhost:3011
|
||||
QUESTIONS_API_PREFIX=/api/v1
|
||||
|
||||
# Mana Core Auth
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
29
services/matrix-questions-bot/.gitignore
vendored
Normal file
29
services/matrix-questions-bot/.gitignore
vendored
Normal 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
|
||||
234
services/matrix-questions-bot/CLAUDE.md
Normal file
234
services/matrix-questions-bot/CLAUDE.md
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
# Matrix Questions Bot - Claude Code Guidelines
|
||||
|
||||
## Overview
|
||||
|
||||
Matrix Questions Bot provides Q&A research management via Matrix chat. It integrates with the Questions backend for question management, web research via mana-search, answer tracking, and collection organization.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: NestJS 10
|
||||
- **Matrix**: matrix-bot-sdk
|
||||
- **Backend**: Questions API (port 3011)
|
||||
- **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-questions-bot/
|
||||
├── src/
|
||||
│ ├── main.ts # Application entry point (port 3324)
|
||||
│ ├── 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
|
||||
│ ├── questions/
|
||||
│ │ ├── questions.module.ts
|
||||
│ │ └── questions.service.ts # Questions 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 |
|
||||
|
||||
### Question Management
|
||||
|
||||
| Command | Aliases | Description |
|
||||
|---------|---------|-------------|
|
||||
| `!fragen` | questions, liste | List all questions |
|
||||
| `!fragen offen` | - | Filter by status |
|
||||
| `!frage [nr]` | question, details | Show question details |
|
||||
| `!neu Frage?` | new, ask | Create new question |
|
||||
| `!loeschen [nr]` | delete | Delete question |
|
||||
| `!archivieren [nr]` | archive | Archive question |
|
||||
|
||||
### Research
|
||||
|
||||
| Command | Aliases | Description |
|
||||
|---------|---------|-------------|
|
||||
| `!recherche [nr]` | research | Start quick research |
|
||||
| `!recherche [nr] standard` | - | Standard research (15 sources) |
|
||||
| `!recherche [nr] deep` | - | Deep research (30 sources) |
|
||||
| `!ergebnis [nr]` | result | Show research result |
|
||||
| `!quellen [nr]` | sources | Show sources |
|
||||
|
||||
### Answers
|
||||
|
||||
| Command | Aliases | Description |
|
||||
|---------|---------|-------------|
|
||||
| `!antwort [nr]` | answer | Show answer |
|
||||
| `!bewerten [nr] 1-5` | rate | Rate answer |
|
||||
| `!akzeptieren [nr]` | accept | Accept as solution |
|
||||
|
||||
### Collections
|
||||
|
||||
| Command | Aliases | Description |
|
||||
|---------|---------|-------------|
|
||||
| `!sammlungen` | collections | List collections |
|
||||
| `!sammlung Name` | collection | Create collection |
|
||||
|
||||
### Search
|
||||
|
||||
| Command | Aliases | Description |
|
||||
|---------|---------|-------------|
|
||||
| `!suche Begriff` | search | Search questions |
|
||||
|
||||
## Research Depths
|
||||
|
||||
| Depth | Sources | Content Extraction | Categories |
|
||||
|-------|---------|-------------------|------------|
|
||||
| `quick` | 5 | No | general |
|
||||
| `standard` | 15 | Yes | general, news |
|
||||
| `deep` | 30 | Yes | general, news, science, it |
|
||||
|
||||
## Question Status
|
||||
|
||||
| Status | Emoji | Description |
|
||||
|--------|-------|-------------|
|
||||
| `open` | ❓ | New question |
|
||||
| `researching` | 🔍 | Research in progress |
|
||||
| `answered` | ✅ | Has answer |
|
||||
| `archived` | 📦 | Archived |
|
||||
|
||||
## Priority Levels
|
||||
|
||||
| Priority | Indicator |
|
||||
|----------|-----------|
|
||||
| `urgent` | 🔴 |
|
||||
| `high` | 🟠 |
|
||||
| `normal` | (none) |
|
||||
| `low` | (none) |
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
# Login
|
||||
!login max@example.com mypassword
|
||||
|
||||
# Create a new question
|
||||
!neu Was ist Quantencomputing?
|
||||
|
||||
# List questions
|
||||
!fragen
|
||||
|
||||
# Start research
|
||||
!recherche 1 standard
|
||||
|
||||
# View sources
|
||||
!quellen 1
|
||||
|
||||
# View answer
|
||||
!antwort 1
|
||||
|
||||
# Rate the answer
|
||||
!bewerten 1 5
|
||||
|
||||
# Accept as solution
|
||||
!akzeptieren 1
|
||||
|
||||
# Search questions
|
||||
!suche quantum
|
||||
|
||||
# Create collection
|
||||
!sammlung Wissenschaft
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# Server
|
||||
PORT=3324
|
||||
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||
MATRIX_ACCESS_TOKEN=syt_xxx
|
||||
MATRIX_ALLOWED_ROOMS=#questions:matrix.mana.how
|
||||
MATRIX_STORAGE_PATH=./data/bot-storage.json
|
||||
|
||||
# Questions Backend
|
||||
QUESTIONS_BACKEND_URL=http://localhost:3011
|
||||
QUESTIONS_API_PREFIX=/api/v1
|
||||
|
||||
# Mana Core Auth
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Build locally
|
||||
docker build -f services/matrix-questions-bot/Dockerfile -t matrix-questions-bot services/matrix-questions-bot
|
||||
|
||||
# Run
|
||||
docker run -p 3324:3324 \
|
||||
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
|
||||
-e MATRIX_ACCESS_TOKEN=syt_xxx \
|
||||
-e QUESTIONS_BACKEND_URL=http://questions-backend:3011 \
|
||||
-e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \
|
||||
-v matrix-questions-bot-data:/app/data \
|
||||
matrix-questions-bot
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:3324/health
|
||||
```
|
||||
|
||||
## Questions Backend API Endpoints Used
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/health` | GET | Health check |
|
||||
| `/api/v1/questions` | GET | List questions |
|
||||
| `/api/v1/questions` | POST | Create question |
|
||||
| `/api/v1/questions/:id` | GET | Get question |
|
||||
| `/api/v1/questions/:id` | DELETE | Delete question |
|
||||
| `/api/v1/questions/:id/status` | PUT | Update status |
|
||||
| `/api/v1/research/start` | POST | Start research |
|
||||
| `/api/v1/research/question/:id` | GET | Get research results |
|
||||
| `/api/v1/sources/question/:id` | GET | Get sources |
|
||||
| `/api/v1/answers/question/:id` | GET | Get answers |
|
||||
| `/api/v1/answers/:id/rate` | POST | Rate answer |
|
||||
| `/api/v1/answers/:id/accept` | POST | Accept answer |
|
||||
| `/api/v1/collections` | GET | List collections |
|
||||
| `/api/v1/collections` | POST | Create collection |
|
||||
|
||||
## Number-Based Reference System
|
||||
|
||||
The bot uses a number-based reference system for ease of use:
|
||||
1. User runs `!fragen` to get a list of questions
|
||||
2. Bot stores the list internally for the user
|
||||
3. User can reference questions by their list number
|
||||
4. Numbers are valid until the user runs a new list command
|
||||
|
||||
This allows simple commands like:
|
||||
- `!frage 3` - Show details for question #3
|
||||
- `!recherche 1 deep` - Start deep research for question #1
|
||||
- `!antwort 2` - Show answer for question #2
|
||||
41
services/matrix-questions-bot/Dockerfile
Normal file
41
services/matrix-questions-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 3324
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3324/health || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "dist/main.js"]
|
||||
5
services/matrix-questions-bot/nest-cli.json
Normal file
5
services/matrix-questions-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
27
services/matrix-questions-bot/package.json
Normal file
27
services/matrix-questions-bot/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "@mana-bots/matrix-questions-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Matrix bot for Q&A research management",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"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.1.13",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
21
services/matrix-questions-bot/src/app.module.ts
Normal file
21
services/matrix-questions-bot/src/app.module.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { HealthController } from './health.controller';
|
||||
import { BotModule } from './bot/bot.module';
|
||||
import { QuestionsModule } from './questions/questions.module';
|
||||
import { SessionModule } from './session/session.module';
|
||||
import configuration from './config/configuration';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
}),
|
||||
BotModule,
|
||||
QuestionsModule,
|
||||
SessionModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
11
services/matrix-questions-bot/src/bot/bot.module.ts
Normal file
11
services/matrix-questions-bot/src/bot/bot.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MatrixService } from './matrix.service';
|
||||
import { QuestionsModule } from '../questions/questions.module';
|
||||
import { SessionModule } from '../session/session.module';
|
||||
|
||||
@Module({
|
||||
imports: [QuestionsModule, SessionModule],
|
||||
providers: [MatrixService],
|
||||
exports: [MatrixService],
|
||||
})
|
||||
export class BotModule {}
|
||||
745
services/matrix-questions-bot/src/bot/matrix.service.ts
Normal file
745
services/matrix-questions-bot/src/bot/matrix.service.ts
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
MatrixClient,
|
||||
SimpleFsStorageProvider,
|
||||
AutojoinRoomsMixin,
|
||||
} from 'matrix-bot-sdk';
|
||||
import { QuestionsService, Question, Collection, Answer } from '../questions/questions.service';
|
||||
import { SessionService } from '../session/session.service';
|
||||
import { HELP_MESSAGE } from '../config/configuration';
|
||||
|
||||
@Injectable()
|
||||
export class MatrixService implements OnModuleInit {
|
||||
private readonly logger = new Logger(MatrixService.name);
|
||||
private client: MatrixClient;
|
||||
private allowedRooms: string[];
|
||||
|
||||
// Store last shown items per user for reference by number
|
||||
private lastQuestionsList: Map<string, Question[]> = new Map();
|
||||
private lastCollectionsList: Map<string, Collection[]> = new Map();
|
||||
private lastAnswersList: Map<string, Answer[]> = new Map();
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private questionsService: QuestionsService,
|
||||
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 Questions 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;
|
||||
|
||||
// Check allowed rooms
|
||||
if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = event.sender;
|
||||
const parts = body.slice(1).split(/\s+/);
|
||||
const command = parts[0].toLowerCase();
|
||||
const args = parts.slice(1);
|
||||
const argString = args.join(' ');
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'help':
|
||||
case 'hilfe':
|
||||
await this.sendHtml(roomId, HELP_MESSAGE);
|
||||
break;
|
||||
|
||||
case 'login':
|
||||
await this.handleLogin(roomId, sender, args);
|
||||
break;
|
||||
|
||||
case 'logout':
|
||||
this.sessionService.logout(sender);
|
||||
await this.sendHtml(roomId, '<p>Erfolgreich abgemeldet.</p>');
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
await this.handleStatus(roomId, sender);
|
||||
break;
|
||||
|
||||
// Question commands
|
||||
case 'fragen':
|
||||
case 'questions':
|
||||
case 'liste':
|
||||
await this.handleListQuestions(roomId, sender, args[0]);
|
||||
break;
|
||||
|
||||
case 'frage':
|
||||
case 'question':
|
||||
case 'details':
|
||||
await this.handleQuestionDetails(roomId, sender, args[0]);
|
||||
break;
|
||||
|
||||
case 'neu':
|
||||
case 'new':
|
||||
case 'ask':
|
||||
await this.handleCreateQuestion(roomId, sender, argString);
|
||||
break;
|
||||
|
||||
case 'loeschen':
|
||||
case 'delete':
|
||||
await this.handleDeleteQuestion(roomId, sender, args[0]);
|
||||
break;
|
||||
|
||||
case 'archivieren':
|
||||
case 'archive':
|
||||
await this.handleArchiveQuestion(roomId, sender, args[0]);
|
||||
break;
|
||||
|
||||
// Research commands
|
||||
case 'recherche':
|
||||
case 'research':
|
||||
await this.handleStartResearch(roomId, sender, args[0], args[1]);
|
||||
break;
|
||||
|
||||
case 'ergebnis':
|
||||
case 'result':
|
||||
await this.handleResearchResult(roomId, sender, args[0]);
|
||||
break;
|
||||
|
||||
case 'quellen':
|
||||
case 'sources':
|
||||
await this.handleSources(roomId, sender, args[0]);
|
||||
break;
|
||||
|
||||
// Answer commands
|
||||
case 'antwort':
|
||||
case 'answer':
|
||||
await this.handleAnswer(roomId, sender, args[0]);
|
||||
break;
|
||||
|
||||
case 'bewerten':
|
||||
case 'rate':
|
||||
await this.handleRateAnswer(roomId, sender, args[0], args[1]);
|
||||
break;
|
||||
|
||||
case 'akzeptieren':
|
||||
case 'accept':
|
||||
await this.handleAcceptAnswer(roomId, sender, args[0]);
|
||||
break;
|
||||
|
||||
// Collection commands
|
||||
case 'sammlungen':
|
||||
case 'collections':
|
||||
await this.handleListCollections(roomId, sender);
|
||||
break;
|
||||
|
||||
case 'sammlung':
|
||||
case 'collection':
|
||||
await this.handleCreateCollection(roomId, sender, argString);
|
||||
break;
|
||||
|
||||
// Search
|
||||
case 'suche':
|
||||
case 'search':
|
||||
await this.handleSearch(roomId, sender, argString);
|
||||
break;
|
||||
|
||||
default:
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p>Unbekannter Befehl: <code>${command}</code>. Nutze <code>!help</code> fuer Hilfe.</p>`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error handling command ${command}:`, error);
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${error.message}</p>`);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendHtml(roomId: string, html: string) {
|
||||
await this.client.sendMessage(roomId, {
|
||||
msgtype: 'm.text',
|
||||
body: html.replace(/<[^>]*>/g, ''),
|
||||
format: 'org.matrix.custom.html',
|
||||
formatted_body: html,
|
||||
});
|
||||
}
|
||||
|
||||
private requireAuth(sender: string): string {
|
||||
const token = this.sessionService.getToken(sender);
|
||||
if (!token) {
|
||||
throw new Error('Nicht angemeldet. Nutze <code>!login email passwort</code>');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// Auth handlers
|
||||
private async handleLogin(roomId: string, sender: string, args: string[]) {
|
||||
if (args.length < 2) {
|
||||
await this.sendHtml(roomId, '<p>Verwendung: <code>!login email passwort</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const [email, password] = args;
|
||||
const result = await this.sessionService.login(sender, email, password);
|
||||
|
||||
if (result.success) {
|
||||
await this.sendHtml(roomId, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
|
||||
} else {
|
||||
await this.sendHtml(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStatus(roomId: string, sender: string) {
|
||||
const backendOk = await this.questionsService.checkHealth();
|
||||
const loggedIn = this.sessionService.isLoggedIn(sender);
|
||||
const sessions = this.sessionService.getSessionCount();
|
||||
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<h3>Questions Bot Status</h3>
|
||||
<ul>
|
||||
<li>Backend: ${backendOk ? 'Online' : 'Offline'}</li>
|
||||
<li>Angemeldet: ${loggedIn ? 'Ja' : 'Nein'}</li>
|
||||
<li>Aktive Sessions: ${sessions}</li>
|
||||
</ul>`
|
||||
);
|
||||
}
|
||||
|
||||
// Question handlers
|
||||
private async handleListQuestions(roomId: string, sender: string, statusFilter?: string) {
|
||||
const token = this.requireAuth(sender);
|
||||
|
||||
const options: any = {};
|
||||
if (statusFilter) {
|
||||
const statusMap: Record<string, string> = {
|
||||
offen: 'open',
|
||||
open: 'open',
|
||||
recherche: 'researching',
|
||||
researching: 'researching',
|
||||
beantwortet: 'answered',
|
||||
answered: 'answered',
|
||||
archiviert: 'archived',
|
||||
archived: 'archived',
|
||||
};
|
||||
options.status = statusMap[statusFilter.toLowerCase()] || statusFilter;
|
||||
}
|
||||
|
||||
const result = await this.questionsService.getQuestions(token, options);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const questions = result.data || [];
|
||||
this.lastQuestionsList.set(sender, questions);
|
||||
|
||||
if (questions.length === 0) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
'<p>Keine Fragen vorhanden. Stelle eine mit <code>!neu Frage?</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<h3>Deine Fragen</h3><ol>';
|
||||
for (const q of questions) {
|
||||
const status = this.getStatusEmoji(q.status);
|
||||
const priority = this.getPriorityIndicator(q.priority);
|
||||
html += `<li>${status} ${priority}<strong>${q.title}</strong></li>`;
|
||||
}
|
||||
html += '</ol>';
|
||||
html += '<p><em>Nutze <code>!frage [nr]</code> fuer Details oder <code>!recherche [nr]</code></em></p>';
|
||||
|
||||
await this.sendHtml(roomId, html);
|
||||
}
|
||||
|
||||
private async handleQuestionDetails(roomId: string, sender: string, numberStr: string) {
|
||||
const token = this.requireAuth(sender);
|
||||
const question = this.getQuestionByNumber(sender, numberStr);
|
||||
|
||||
if (!question) {
|
||||
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.questionsService.getQuestion(token, question.id);
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const q = result.data!;
|
||||
const status = this.getStatusEmoji(q.status);
|
||||
let html = `<h3>${status} ${q.title}</h3>`;
|
||||
|
||||
if (q.description) html += `<p>${q.description}</p>`;
|
||||
|
||||
html += '<ul>';
|
||||
html += `<li>Status: ${this.translateStatus(q.status)}</li>`;
|
||||
html += `<li>Prioritaet: ${this.translatePriority(q.priority)}</li>`;
|
||||
html += `<li>Recherche-Tiefe: ${q.researchDepth}</li>`;
|
||||
if (q.tags?.length) html += `<li>Tags: ${q.tags.join(', ')}</li>`;
|
||||
if (q.category) html += `<li>Kategorie: ${q.category}</li>`;
|
||||
html += `<li>Erstellt: ${new Date(q.createdAt).toLocaleDateString('de-DE')}</li>`;
|
||||
if (q.answeredAt) html += `<li>Beantwortet: ${new Date(q.answeredAt).toLocaleDateString('de-DE')}</li>`;
|
||||
html += '</ul>';
|
||||
|
||||
html += `<p><em>Nutze <code>!recherche ${numberStr}</code> um eine Recherche zu starten</em></p>`;
|
||||
|
||||
await this.sendHtml(roomId, html);
|
||||
}
|
||||
|
||||
private async handleCreateQuestion(roomId: string, sender: string, title: string) {
|
||||
if (!title) {
|
||||
await this.sendHtml(roomId, '<p>Verwendung: <code>!neu Deine Frage?</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this.requireAuth(sender);
|
||||
const result = await this.questionsService.createQuestion(token, title);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastQuestionsList.delete(sender);
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p>Frage erstellt: <strong>${result.data!.title}</strong></p>
|
||||
<p><em>Nutze <code>!fragen</code> und dann <code>!recherche [nr]</code> um zu recherchieren.</em></p>`
|
||||
);
|
||||
}
|
||||
|
||||
private async handleDeleteQuestion(roomId: string, sender: string, numberStr: string) {
|
||||
const token = this.requireAuth(sender);
|
||||
const question = this.getQuestionByNumber(sender, numberStr);
|
||||
|
||||
if (!question) {
|
||||
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.questionsService.deleteQuestion(token, question.id);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastQuestionsList.delete(sender);
|
||||
await this.sendHtml(roomId, `<p>Frage geloescht: <strong>${question.title}</strong></p>`);
|
||||
}
|
||||
|
||||
private async handleArchiveQuestion(roomId: string, sender: string, numberStr: string) {
|
||||
const token = this.requireAuth(sender);
|
||||
const question = this.getQuestionByNumber(sender, numberStr);
|
||||
|
||||
if (!question) {
|
||||
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.questionsService.updateQuestionStatus(token, question.id, 'archived');
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendHtml(roomId, `<p>Frage archiviert: <strong>${question.title}</strong></p>`);
|
||||
}
|
||||
|
||||
// Research handlers
|
||||
private async handleStartResearch(roomId: string, sender: string, numberStr: string, depthStr?: string) {
|
||||
const token = this.requireAuth(sender);
|
||||
const question = this.getQuestionByNumber(sender, numberStr);
|
||||
|
||||
if (!question) {
|
||||
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const depthMap: Record<string, 'quick' | 'standard' | 'deep'> = {
|
||||
schnell: 'quick',
|
||||
quick: 'quick',
|
||||
standard: 'standard',
|
||||
normal: 'standard',
|
||||
tief: 'deep',
|
||||
deep: 'deep',
|
||||
};
|
||||
const depth = depthMap[depthStr?.toLowerCase() || ''] || 'quick';
|
||||
|
||||
await this.sendHtml(roomId, `<p>Starte ${depth}-Recherche fuer: <strong>${question.title}</strong>...</p>`);
|
||||
|
||||
const result = await this.questionsService.startResearch(token, question.id, depth);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const research = result.data!;
|
||||
let html = `<h3>Recherche abgeschlossen</h3>`;
|
||||
|
||||
if (research.summary) {
|
||||
html += `<p><strong>Zusammenfassung:</strong></p><p>${research.summary}</p>`;
|
||||
}
|
||||
|
||||
if (research.keyPoints?.length) {
|
||||
html += '<p><strong>Wichtige Punkte:</strong></p><ul>';
|
||||
for (const point of research.keyPoints.slice(0, 5)) {
|
||||
html += `<li>${point}</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
if (research.followUpQuestions?.length) {
|
||||
html += '<p><strong>Folge-Fragen:</strong></p><ul>';
|
||||
for (const fq of research.followUpQuestions.slice(0, 3)) {
|
||||
html += `<li>${fq}</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
html += `<p><em>Nutze <code>!quellen ${numberStr}</code> fuer die Quellen</em></p>`;
|
||||
|
||||
await this.sendHtml(roomId, html);
|
||||
}
|
||||
|
||||
private async handleResearchResult(roomId: string, sender: string, numberStr: string) {
|
||||
const token = this.requireAuth(sender);
|
||||
const question = this.getQuestionByNumber(sender, numberStr);
|
||||
|
||||
if (!question) {
|
||||
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.questionsService.getResearchResults(token, question.id);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = result.data || [];
|
||||
|
||||
if (results.length === 0) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p>Keine Recherche-Ergebnisse. Nutze <code>!recherche ${numberStr}</code></p>`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const latest = results[0];
|
||||
let html = `<h3>Recherche-Ergebnis</h3>`;
|
||||
html += `<p><em>Tiefe: ${latest.researchDepth}</em></p>`;
|
||||
|
||||
if (latest.summary) {
|
||||
html += `<p>${latest.summary}</p>`;
|
||||
}
|
||||
|
||||
if (latest.keyPoints?.length) {
|
||||
html += '<p><strong>Wichtige Punkte:</strong></p><ul>';
|
||||
for (const point of latest.keyPoints) {
|
||||
html += `<li>${point}</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
await this.sendHtml(roomId, html);
|
||||
}
|
||||
|
||||
private async handleSources(roomId: string, sender: string, numberStr: string) {
|
||||
const token = this.requireAuth(sender);
|
||||
const question = this.getQuestionByNumber(sender, numberStr);
|
||||
|
||||
if (!question) {
|
||||
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.questionsService.getSources(token, question.id);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = result.data || [];
|
||||
|
||||
if (sources.length === 0) {
|
||||
await this.sendHtml(roomId, '<p>Keine Quellen vorhanden.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<h3>Quellen fuer: ${question.title}</h3><ol>`;
|
||||
for (const source of sources.slice(0, 10)) {
|
||||
const relevance = source.relevanceScore ? ` (${Math.round(source.relevanceScore * 100)}%)` : '';
|
||||
html += `<li><a href="${source.url}">${source.title}</a>${relevance}<br/><em>${source.domain}</em></li>`;
|
||||
}
|
||||
html += '</ol>';
|
||||
|
||||
if (sources.length > 10) {
|
||||
html += `<p><em>...und ${sources.length - 10} weitere Quellen</em></p>`;
|
||||
}
|
||||
|
||||
await this.sendHtml(roomId, html);
|
||||
}
|
||||
|
||||
// Answer handlers
|
||||
private async handleAnswer(roomId: string, sender: string, numberStr: string) {
|
||||
const token = this.requireAuth(sender);
|
||||
const question = this.getQuestionByNumber(sender, numberStr);
|
||||
|
||||
if (!question) {
|
||||
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!fragen</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.questionsService.getAnswers(token, question.id);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const answers = result.data || [];
|
||||
this.lastAnswersList.set(sender, answers);
|
||||
|
||||
if (answers.length === 0) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
`<p>Keine Antworten. Starte zuerst eine Recherche mit <code>!recherche ${numberStr}</code></p>`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the first (most recent) answer
|
||||
const answer = answers[0];
|
||||
const accepted = answer.isAccepted ? ' ✅' : '';
|
||||
const rating = answer.rating ? ` (${answer.rating}/5 Sterne)` : '';
|
||||
const confidence = answer.confidence ? ` [${Math.round(answer.confidence * 100)}% Konfidenz]` : '';
|
||||
|
||||
let html = `<h3>Antwort${accepted}${rating}</h3>`;
|
||||
html += `<p><em>Model: ${answer.modelId}${confidence}</em></p>`;
|
||||
|
||||
if (answer.summary) {
|
||||
html += `<p><strong>Zusammenfassung:</strong> ${answer.summary}</p>`;
|
||||
}
|
||||
|
||||
html += `<p>${answer.contentMarkdown || answer.content}</p>`;
|
||||
|
||||
if (answer.sourceCount) {
|
||||
html += `<p><em>Basierend auf ${answer.sourceCount} Quellen</em></p>`;
|
||||
}
|
||||
|
||||
html += `<p><em>Nutze <code>!bewerten ${numberStr} 1-5</code> zum Bewerten</em></p>`;
|
||||
|
||||
await this.sendHtml(roomId, html);
|
||||
}
|
||||
|
||||
private async handleRateAnswer(roomId: string, sender: string, numberStr: string, ratingStr: string) {
|
||||
const token = this.requireAuth(sender);
|
||||
const answers = this.lastAnswersList.get(sender);
|
||||
|
||||
if (!answers || answers.length === 0) {
|
||||
await this.sendHtml(roomId, '<p>Zeige zuerst eine Antwort mit <code>!antwort [nr]</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const rating = parseInt(ratingStr, 10);
|
||||
if (isNaN(rating) || rating < 1 || rating > 5) {
|
||||
await this.sendHtml(roomId, '<p>Bewertung muss zwischen 1 und 5 sein.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const answer = answers[0];
|
||||
const result = await this.questionsService.rateAnswer(token, answer.id, rating);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendHtml(roomId, `<p>Antwort mit ${rating} Sternen bewertet.</p>`);
|
||||
}
|
||||
|
||||
private async handleAcceptAnswer(roomId: string, sender: string, numberStr: string) {
|
||||
const token = this.requireAuth(sender);
|
||||
const answers = this.lastAnswersList.get(sender);
|
||||
|
||||
if (!answers || answers.length === 0) {
|
||||
await this.sendHtml(roomId, '<p>Zeige zuerst eine Antwort mit <code>!antwort [nr]</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const answer = answers[0];
|
||||
const result = await this.questionsService.acceptAnswer(token, answer.id);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendHtml(roomId, '<p>Antwort als Loesung akzeptiert. ✅</p>');
|
||||
}
|
||||
|
||||
// Collection handlers
|
||||
private async handleListCollections(roomId: string, sender: string) {
|
||||
const token = this.requireAuth(sender);
|
||||
const result = await this.questionsService.getCollections(token);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const collections = result.data || [];
|
||||
this.lastCollectionsList.set(sender, collections);
|
||||
|
||||
if (collections.length === 0) {
|
||||
await this.sendHtml(
|
||||
roomId,
|
||||
'<p>Keine Sammlungen. Erstelle eine mit <code>!sammlung Name</code></p>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<h3>Sammlungen</h3><ol>';
|
||||
for (const c of collections) {
|
||||
const defaultMark = c.isDefault ? ' (Standard)' : '';
|
||||
const count = c.questionCount !== undefined ? ` [${c.questionCount} Fragen]` : '';
|
||||
html += `<li><strong>${c.name}</strong>${defaultMark}${count}</li>`;
|
||||
}
|
||||
html += '</ol>';
|
||||
|
||||
await this.sendHtml(roomId, html);
|
||||
}
|
||||
|
||||
private async handleCreateCollection(roomId: string, sender: string, name: string) {
|
||||
if (!name) {
|
||||
await this.sendHtml(roomId, '<p>Verwendung: <code>!sammlung Name</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this.requireAuth(sender);
|
||||
const result = await this.questionsService.createCollection(token, name);
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastCollectionsList.delete(sender);
|
||||
await this.sendHtml(roomId, `<p>Sammlung <strong>${result.data!.name}</strong> erstellt.</p>`);
|
||||
}
|
||||
|
||||
// Search handler
|
||||
private async handleSearch(roomId: string, sender: string, query: string) {
|
||||
if (!query) {
|
||||
await this.sendHtml(roomId, '<p>Verwendung: <code>!suche Begriff</code></p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this.requireAuth(sender);
|
||||
const result = await this.questionsService.getQuestions(token, { search: query });
|
||||
|
||||
if (result.error) {
|
||||
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const questions = result.data || [];
|
||||
this.lastQuestionsList.set(sender, questions);
|
||||
|
||||
if (questions.length === 0) {
|
||||
await this.sendHtml(roomId, `<p>Keine Fragen gefunden fuer "${query}"</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<h3>Suchergebnisse: "${query}"</h3><ol>`;
|
||||
for (const q of questions) {
|
||||
const status = this.getStatusEmoji(q.status);
|
||||
html += `<li>${status} <strong>${q.title}</strong></li>`;
|
||||
}
|
||||
html += '</ol>';
|
||||
|
||||
await this.sendHtml(roomId, html);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private getQuestionByNumber(sender: string, numberStr: string): Question | null {
|
||||
const questions = this.lastQuestionsList.get(sender);
|
||||
if (!questions) return null;
|
||||
|
||||
const index = parseInt(numberStr, 10) - 1;
|
||||
if (isNaN(index) || index < 0 || index >= questions.length) return null;
|
||||
|
||||
return questions[index];
|
||||
}
|
||||
|
||||
private getStatusEmoji(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
open: '❓', // Question mark
|
||||
researching: '🔍', // Magnifying glass
|
||||
answered: '✅', // Check mark
|
||||
archived: '📦', // Package
|
||||
};
|
||||
return map[status] || '❓';
|
||||
}
|
||||
|
||||
private translateStatus(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
open: 'Offen',
|
||||
researching: 'In Recherche',
|
||||
answered: 'Beantwortet',
|
||||
archived: 'Archiviert',
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
private getPriorityIndicator(priority: string): string {
|
||||
const map: Record<string, string> = {
|
||||
urgent: '🔴 ', // Red circle
|
||||
high: '🟠 ', // Orange circle
|
||||
normal: '',
|
||||
low: '',
|
||||
};
|
||||
return map[priority] || '';
|
||||
}
|
||||
|
||||
private translatePriority(priority: string): string {
|
||||
const map: Record<string, string> = {
|
||||
low: 'Niedrig',
|
||||
normal: 'Normal',
|
||||
high: 'Hoch',
|
||||
urgent: 'Dringend',
|
||||
};
|
||||
return map[priority] || priority;
|
||||
}
|
||||
}
|
||||
69
services/matrix-questions-bot/src/config/configuration.ts
Normal file
69
services/matrix-questions-bot/src/config/configuration.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
export default () => ({
|
||||
port: parseInt(process.env.PORT, 10) || 3324,
|
||||
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',
|
||||
},
|
||||
questions: {
|
||||
backendUrl: process.env.QUESTIONS_BACKEND_URL || 'http://localhost:3011',
|
||||
apiPrefix: process.env.QUESTIONS_API_PREFIX || '/api/v1',
|
||||
},
|
||||
auth: {
|
||||
url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
|
||||
},
|
||||
});
|
||||
|
||||
export const HELP_MESSAGE = `<h2>Questions Bot - Befehle</h2>
|
||||
|
||||
<h3>Authentifizierung</h3>
|
||||
<ul>
|
||||
<li><code>!login email passwort</code> - Anmelden</li>
|
||||
<li><code>!logout</code> - Abmelden</li>
|
||||
<li><code>!status</code> - Bot-Status anzeigen</li>
|
||||
</ul>
|
||||
|
||||
<h3>Fragen</h3>
|
||||
<ul>
|
||||
<li><code>!fragen</code> - Alle Fragen auflisten</li>
|
||||
<li><code>!fragen offen</code> - Offene Fragen</li>
|
||||
<li><code>!frage [nr]</code> - Frage-Details anzeigen</li>
|
||||
<li><code>!neu Frage?</code> - Neue Frage stellen</li>
|
||||
<li><code>!loeschen [nr]</code> - Frage loeschen</li>
|
||||
<li><code>!archivieren [nr]</code> - Frage archivieren</li>
|
||||
</ul>
|
||||
|
||||
<h3>Recherche</h3>
|
||||
<ul>
|
||||
<li><code>!recherche [nr]</code> - Recherche starten (quick)</li>
|
||||
<li><code>!recherche [nr] standard</code> - Standard-Recherche</li>
|
||||
<li><code>!recherche [nr] deep</code> - Tiefe Recherche</li>
|
||||
<li><code>!ergebnis [nr]</code> - Recherche-Ergebnis anzeigen</li>
|
||||
<li><code>!quellen [nr]</code> - Quellen anzeigen</li>
|
||||
</ul>
|
||||
|
||||
<h3>Antworten</h3>
|
||||
<ul>
|
||||
<li><code>!antwort [nr]</code> - Antwort zur Frage anzeigen</li>
|
||||
<li><code>!bewerten [nr] 1-5</code> - Antwort bewerten</li>
|
||||
<li><code>!akzeptieren [nr]</code> - Antwort akzeptieren</li>
|
||||
</ul>
|
||||
|
||||
<h3>Sammlungen</h3>
|
||||
<ul>
|
||||
<li><code>!sammlungen</code> - Alle Sammlungen</li>
|
||||
<li><code>!sammlung [name]</code> - Neue Sammlung erstellen</li>
|
||||
</ul>
|
||||
|
||||
<h3>Suche</h3>
|
||||
<ul>
|
||||
<li><code>!suche Begriff</code> - Fragen durchsuchen</li>
|
||||
</ul>
|
||||
|
||||
<h3>Weitere Befehle</h3>
|
||||
<ul>
|
||||
<li><code>!help</code> - Diese Hilfe anzeigen</li>
|
||||
</ul>
|
||||
|
||||
<p><em>Tipp: Nutze Nummern aus der zuletzt angezeigten Liste.</em></p>`;
|
||||
9
services/matrix-questions-bot/src/health.controller.ts
Normal file
9
services/matrix-questions-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-questions-bot' };
|
||||
}
|
||||
}
|
||||
10
services/matrix-questions-bot/src/main.ts
Normal file
10
services/matrix-questions-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 || 3324;
|
||||
await app.listen(port);
|
||||
console.log(`Matrix Questions Bot running on port ${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { QuestionsService } from './questions.service';
|
||||
|
||||
@Module({
|
||||
providers: [QuestionsService],
|
||||
exports: [QuestionsService],
|
||||
})
|
||||
export class QuestionsModule {}
|
||||
219
services/matrix-questions-bot/src/questions/questions.service.ts
Normal file
219
services/matrix-questions-bot/src/questions/questions.service.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface Question {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'open' | 'researching' | 'answered' | 'archived';
|
||||
priority: 'low' | 'normal' | 'high' | 'urgent';
|
||||
tags: string[];
|
||||
category?: string;
|
||||
researchDepth: 'quick' | 'standard' | 'deep';
|
||||
collectionId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
answeredAt?: string;
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
isDefault: boolean;
|
||||
questionCount?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ResearchResult {
|
||||
id: string;
|
||||
questionId: string;
|
||||
researchDepth: string;
|
||||
summary?: string;
|
||||
keyPoints?: string[];
|
||||
followUpQuestions?: string[];
|
||||
createdAt: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
snippet?: string;
|
||||
domain: string;
|
||||
relevanceScore?: number;
|
||||
position: number;
|
||||
engine: string;
|
||||
}
|
||||
|
||||
export interface Answer {
|
||||
id: string;
|
||||
questionId: string;
|
||||
content: string;
|
||||
contentMarkdown?: string;
|
||||
summary?: string;
|
||||
modelId: string;
|
||||
provider: string;
|
||||
confidence?: number;
|
||||
sourceCount?: number;
|
||||
rating?: number;
|
||||
isAccepted: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QuestionsService {
|
||||
private readonly logger = new Logger(QuestionsService.name);
|
||||
private backendUrl: string;
|
||||
private apiPrefix: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.backendUrl = this.configService.get<string>('questions.backendUrl') || 'http://localhost:3011';
|
||||
this.apiPrefix = this.configService.get<string>('questions.apiPrefix') || '/api/v1';
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
token: string,
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<{ data?: T; error?: string }> {
|
||||
try {
|
||||
const url = `${this.backendUrl}${this.apiPrefix}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return { error: errorData.message || `Fehler: ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
this.logger.error(`Request failed: ${endpoint}`, error);
|
||||
return { error: 'Verbindung zum Backend fehlgeschlagen' };
|
||||
}
|
||||
}
|
||||
|
||||
// Question operations
|
||||
async getQuestions(
|
||||
token: string,
|
||||
options: { status?: string; search?: string; collectionId?: string } = {}
|
||||
): Promise<{ data?: Question[]; error?: string }> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.status) params.set('status', options.status);
|
||||
if (options.search) params.set('search', options.search);
|
||||
if (options.collectionId) params.set('collectionId', options.collectionId);
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
return this.request<Question[]>(token, `/questions${query}`);
|
||||
}
|
||||
|
||||
async getQuestion(token: string, questionId: string): Promise<{ data?: Question; error?: string }> {
|
||||
return this.request<Question>(token, `/questions/${questionId}`);
|
||||
}
|
||||
|
||||
async createQuestion(
|
||||
token: string,
|
||||
title: string,
|
||||
options: { description?: string; priority?: string; tags?: string[]; collectionId?: string } = {}
|
||||
): Promise<{ data?: Question; error?: string }> {
|
||||
return this.request<Question>(token, '/questions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, ...options }),
|
||||
});
|
||||
}
|
||||
|
||||
async updateQuestionStatus(
|
||||
token: string,
|
||||
questionId: string,
|
||||
status: string
|
||||
): Promise<{ data?: Question; error?: string }> {
|
||||
return this.request<Question>(token, `/questions/${questionId}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteQuestion(token: string, questionId: string): Promise<{ error?: string }> {
|
||||
return this.request(token, `/questions/${questionId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// Research operations
|
||||
async startResearch(
|
||||
token: string,
|
||||
questionId: string,
|
||||
depth: 'quick' | 'standard' | 'deep' = 'quick'
|
||||
): Promise<{ data?: ResearchResult; error?: string }> {
|
||||
return this.request<ResearchResult>(token, '/research/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ questionId, depth }),
|
||||
});
|
||||
}
|
||||
|
||||
async getResearchResults(token: string, questionId: string): Promise<{ data?: ResearchResult[]; error?: string }> {
|
||||
return this.request<ResearchResult[]>(token, `/research/question/${questionId}`);
|
||||
}
|
||||
|
||||
async getResearchResult(token: string, researchId: string): Promise<{ data?: ResearchResult; error?: string }> {
|
||||
return this.request<ResearchResult>(token, `/research/${researchId}`);
|
||||
}
|
||||
|
||||
// Source operations
|
||||
async getSources(token: string, questionId: string): Promise<{ data?: Source[]; error?: string }> {
|
||||
return this.request<Source[]>(token, `/sources/question/${questionId}`);
|
||||
}
|
||||
|
||||
// Answer operations
|
||||
async getAnswers(token: string, questionId: string): Promise<{ data?: Answer[]; error?: string }> {
|
||||
return this.request<Answer[]>(token, `/answers/question/${questionId}`);
|
||||
}
|
||||
|
||||
async getAcceptedAnswer(token: string, questionId: string): Promise<{ data?: Answer; error?: string }> {
|
||||
return this.request<Answer>(token, `/answers/question/${questionId}/accepted`);
|
||||
}
|
||||
|
||||
async rateAnswer(token: string, answerId: string, rating: number): Promise<{ data?: Answer; error?: string }> {
|
||||
return this.request<Answer>(token, `/answers/${answerId}/rate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rating }),
|
||||
});
|
||||
}
|
||||
|
||||
async acceptAnswer(token: string, answerId: string): Promise<{ data?: Answer; error?: string }> {
|
||||
return this.request<Answer>(token, `/answers/${answerId}/accept`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// Collection operations
|
||||
async getCollections(token: string): Promise<{ data?: Collection[]; error?: string }> {
|
||||
return this.request<Collection[]>(token, '/collections');
|
||||
}
|
||||
|
||||
async createCollection(
|
||||
token: string,
|
||||
name: string,
|
||||
options: { description?: string; color?: string } = {}
|
||||
): Promise<{ data?: Collection; error?: string }> {
|
||||
return this.request<Collection>(token, '/collections', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, ...options }),
|
||||
});
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.backendUrl}/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Module({
|
||||
providers: [SessionService],
|
||||
exports: [SessionService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
90
services/matrix-questions-bot/src/session/session.service.ts
Normal file
90
services/matrix-questions-bot/src/session/session.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
services/matrix-questions-bot/tsconfig.json
Normal file
22
services/matrix-questions-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