mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
✨ feat(matrix-clock-bot): add Matrix bot for time tracking
Features: - Timer commands: !timer 25m, !stop, !resume, !reset, !status - Alarm commands: !alarm 07:30, !alarms - World clock: !zeit, !weltuhr Berlin, !weltuhren - Voice note support via mana-stt transcription - Natural language parsing for German time formats
This commit is contained in:
parent
bd10762107
commit
dbd14f7134
17 changed files with 1437 additions and 0 deletions
6
services/matrix-clock-bot/.dockerignore
Normal file
6
services/matrix-clock-bot/.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
.env*
|
||||
data
|
||||
15
services/matrix-clock-bot/.env.example
Normal file
15
services/matrix-clock-bot/.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Server
|
||||
PORT=3317
|
||||
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||
MATRIX_ACCESS_TOKEN=syt_xxx_your_bot_token
|
||||
MATRIX_ALLOWED_ROOMS=#clock:matrix.mana.how
|
||||
MATRIX_STORAGE_PATH=./data/bot-storage.json
|
||||
|
||||
# Clock Backend API
|
||||
CLOCK_API_URL=http://localhost:3017/api/v1
|
||||
CLOCK_API_TOKEN=
|
||||
|
||||
# Speech-to-Text (mana-stt service)
|
||||
STT_URL=http://localhost:3020
|
||||
158
services/matrix-clock-bot/CLAUDE.md
Normal file
158
services/matrix-clock-bot/CLAUDE.md
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
# Matrix Clock Bot - Claude Code Guidelines
|
||||
|
||||
## Overview
|
||||
|
||||
Matrix Clock Bot provides time tracking functionality via Matrix chat. Users can create timers, set alarms, and manage world clocks through text commands or voice notes.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: NestJS 10
|
||||
- **Matrix**: matrix-bot-sdk
|
||||
- **Backend**: Clock API (port 3017)
|
||||
- **STT**: mana-stt service (port 3020)
|
||||
|
||||
## 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-clock-bot/
|
||||
├── src/
|
||||
│ ├── main.ts # Application entry point (port 3317)
|
||||
│ ├── 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
|
||||
│ ├── clock/
|
||||
│ │ ├── clock.module.ts
|
||||
│ │ └── clock.service.ts # Clock API client
|
||||
│ └── transcription/
|
||||
│ ├── transcription.module.ts
|
||||
│ └── transcription.service.ts # STT service client
|
||||
├── Dockerfile
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Bot Commands
|
||||
|
||||
### Timer Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!timer 25m` | Create & start 25-minute timer |
|
||||
| `!timer 1h30m` | Create 1.5 hour timer |
|
||||
| `!timer 25m Pomodoro` | Timer with label |
|
||||
| `!stop` | Pause running timer |
|
||||
| `!resume` | Resume paused timer |
|
||||
| `!reset` | Reset timer to start |
|
||||
| `!status` | Show current timer status |
|
||||
| `!timers` | List all timers |
|
||||
|
||||
### Alarm Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!alarm 07:30` | Set alarm for 7:30 |
|
||||
| `!alarm 7 Uhr 30` | German time format |
|
||||
| `!alarm 06:00 Aufstehen!` | Alarm with label |
|
||||
| `!alarms` | List all alarms |
|
||||
|
||||
### World Clock Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!zeit` / `!time` | Current time + world clocks |
|
||||
| `!weltuhr Berlin` | Add world clock |
|
||||
| `!weltuhren` | List world clocks |
|
||||
|
||||
### Natural Language & Voice
|
||||
|
||||
The bot understands natural language:
|
||||
- "Timer 25 Minuten"
|
||||
- "Wecker um 7 Uhr"
|
||||
- "Stop"
|
||||
- "Status"
|
||||
|
||||
Voice notes are transcribed and parsed as commands.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# Server
|
||||
PORT=3317
|
||||
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||
MATRIX_ACCESS_TOKEN=syt_xxx
|
||||
MATRIX_ALLOWED_ROOMS=#clock:matrix.mana.how
|
||||
MATRIX_STORAGE_PATH=./data/bot-storage.json
|
||||
|
||||
# Clock Backend API
|
||||
CLOCK_API_URL=http://localhost:3017/api/v1
|
||||
CLOCK_API_TOKEN=
|
||||
|
||||
# Speech-to-Text
|
||||
STT_URL=http://localhost:3020
|
||||
```
|
||||
|
||||
## Clock API Endpoints Used
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/timers` | GET | List all timers |
|
||||
| `/timers` | POST | Create timer |
|
||||
| `/timers/:id/start` | POST | Start timer |
|
||||
| `/timers/:id/pause` | POST | Pause timer |
|
||||
| `/timers/:id/reset` | POST | Reset timer |
|
||||
| `/alarms` | GET | List alarms |
|
||||
| `/alarms` | POST | Create alarm |
|
||||
| `/alarms/:id/toggle` | PATCH | Toggle alarm |
|
||||
| `/world-clocks` | GET | List world clocks |
|
||||
| `/world-clocks` | POST | Add world clock |
|
||||
| `/timezones/search` | GET | Search timezones (public) |
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Build
|
||||
docker build -f services/matrix-clock-bot/Dockerfile -t matrix-clock-bot services/matrix-clock-bot
|
||||
|
||||
# Run
|
||||
docker run -p 3317:3317 \
|
||||
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
|
||||
-e MATRIX_ACCESS_TOKEN=syt_xxx \
|
||||
-e CLOCK_API_URL=http://clock-backend:3017/api/v1 \
|
||||
-e STT_URL=http://mana-stt:3020 \
|
||||
-v matrix-clock-bot-data:/app/data \
|
||||
matrix-clock-bot
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:3317/health
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
Currently uses a demo token (`CLOCK_API_TOKEN`) for development. Production should implement proper user authentication flow:
|
||||
|
||||
1. User sends `!login` command
|
||||
2. Bot initiates OAuth/auth flow with mana-core-auth
|
||||
3. User token stored per Matrix user ID
|
||||
4. Token used for all Clock API calls
|
||||
25
services/matrix-clock-bot/Dockerfile
Normal file
25
services/matrix-clock-bot/Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN pnpm build
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 3317
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
5
services/matrix-clock-bot/nest-cli.json
Normal file
5
services/matrix-clock-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
28
services/matrix-clock-bot/package.json
Normal file
28
services/matrix-clock-bot/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@manacore/matrix-clock-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Matrix bot for time tracking with Clock app",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.17",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.17",
|
||||
"@nestjs/platform-express": "^10.4.17",
|
||||
"matrix-bot-sdk": "^0.7.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@types/node": "^22.10.7",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
17
services/matrix-clock-bot/src/app.module.ts
Normal file
17
services/matrix-clock-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-clock-bot/src/bot/bot.module.ts
Normal file
11
services/matrix-clock-bot/src/bot/bot.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MatrixService } from './matrix.service';
|
||||
import { ClockModule } from '../clock/clock.module';
|
||||
import { TranscriptionModule } from '../transcription/transcription.module';
|
||||
|
||||
@Module({
|
||||
imports: [ClockModule, TranscriptionModule],
|
||||
providers: [MatrixService],
|
||||
exports: [MatrixService],
|
||||
})
|
||||
export class BotModule {}
|
||||
725
services/matrix-clock-bot/src/bot/matrix.service.ts
Normal file
725
services/matrix-clock-bot/src/bot/matrix.service.ts
Normal file
|
|
@ -0,0 +1,725 @@
|
|||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
MatrixClient,
|
||||
SimpleFsStorageProvider,
|
||||
AutojoinRoomsMixin,
|
||||
RichReply,
|
||||
} from 'matrix-bot-sdk';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { ClockService, Timer, Alarm } from '../clock/clock.service';
|
||||
import { TranscriptionService } from '../transcription/transcription.service';
|
||||
import { HELP_TEXT, WELCOME_TEXT } from '../config/configuration';
|
||||
|
||||
// Natural language keywords
|
||||
const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [
|
||||
{ keywords: ['hilfe', 'help', 'befehle', 'commands'], command: 'help' },
|
||||
{ keywords: ['status', 'timer status', 'laufend'], command: 'status' },
|
||||
{ keywords: ['stop', 'stopp', 'pause', 'anhalten'], command: 'stop' },
|
||||
{ keywords: ['weiter', 'resume', 'fortsetzen'], command: 'resume' },
|
||||
{ keywords: ['zeit', 'time', 'uhrzeit', 'wie spat'], command: 'time' },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class MatrixService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MatrixService.name);
|
||||
private client!: MatrixClient;
|
||||
private readonly homeserverUrl: string;
|
||||
private readonly accessToken: string;
|
||||
private readonly allowedRooms: string[];
|
||||
private readonly storagePath: string;
|
||||
private botUserId: string = '';
|
||||
|
||||
// Demo token for development (TODO: implement proper auth)
|
||||
private readonly demoToken = process.env.CLOCK_API_TOKEN || '';
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private clockService: ClockService,
|
||||
private transcriptionService: TranscriptionService
|
||||
) {
|
||||
this.homeserverUrl = this.configService.get<string>(
|
||||
'matrix.homeserverUrl',
|
||||
'http://localhost:8008'
|
||||
);
|
||||
this.accessToken = this.configService.get<string>('matrix.accessToken', '');
|
||||
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms', []);
|
||||
this.storagePath = this.configService.get<string>(
|
||||
'matrix.storagePath',
|
||||
'./data/bot-storage.json'
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
if (!this.accessToken) {
|
||||
this.logger.warn('No Matrix access token configured. Bot will not start.');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initializeClient();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.client) {
|
||||
await this.client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeClient() {
|
||||
try {
|
||||
const storageDir = path.dirname(this.storagePath);
|
||||
if (!fs.existsSync(storageDir)) {
|
||||
fs.mkdirSync(storageDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = new SimpleFsStorageProvider(this.storagePath);
|
||||
this.client = new MatrixClient(this.homeserverUrl, this.accessToken, storage);
|
||||
|
||||
AutojoinRoomsMixin.setupOnClient(this.client);
|
||||
|
||||
this.client.on('room.invite', async (roomId: string) => {
|
||||
this.logger.log(`Invited to room ${roomId}, joining...`);
|
||||
await this.client.joinRoom(roomId);
|
||||
|
||||
setTimeout(async () => {
|
||||
await this.sendWelcome(roomId);
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
this.client.on('room.message', async (roomId: string, event: any) => {
|
||||
await this.handleMessage(roomId, event);
|
||||
});
|
||||
|
||||
await this.client.start();
|
||||
this.botUserId = await this.client.getUserId();
|
||||
this.logger.log(`Matrix Clock Bot connected as ${this.botUserId}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize Matrix client:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage(roomId: string, event: any) {
|
||||
if (event.sender === this.botUserId) return;
|
||||
|
||||
if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = event.sender;
|
||||
const msgtype = event.content?.msgtype;
|
||||
|
||||
// Handle audio messages
|
||||
if (msgtype === 'm.audio' && event.content?.url) {
|
||||
await this.handleAudioMessage(roomId, event, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msgtype !== 'm.text') return;
|
||||
|
||||
const body = event.content.body?.trim();
|
||||
if (!body) return;
|
||||
|
||||
try {
|
||||
// Check keywords first
|
||||
const keywordCommand = this.detectKeywordCommand(body);
|
||||
if (keywordCommand) {
|
||||
await this.executeCommand(roomId, event, userId, keywordCommand, '');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle ! commands
|
||||
if (body.startsWith('!')) {
|
||||
const [command, ...args] = body.slice(1).split(' ');
|
||||
await this.executeCommand(roomId, event, userId, command.toLowerCase(), args.join(' '));
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to parse as natural timer/alarm command
|
||||
await this.handleNaturalLanguage(roomId, event, userId, body);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error handling message: ${error}`);
|
||||
await this.sendReply(roomId, event, 'Ein Fehler ist aufgetreten.');
|
||||
}
|
||||
}
|
||||
|
||||
private detectKeywordCommand(message: string): string | null {
|
||||
const lowerMessage = message.toLowerCase().trim();
|
||||
if (lowerMessage.length > 50) return null;
|
||||
|
||||
for (const { keywords, command } of KEYWORD_COMMANDS) {
|
||||
for (const keyword of keywords) {
|
||||
if (lowerMessage === keyword || lowerMessage.startsWith(keyword + ' ')) {
|
||||
return command;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async executeCommand(
|
||||
roomId: string,
|
||||
event: any,
|
||||
userId: string,
|
||||
command: string,
|
||||
args: string
|
||||
) {
|
||||
switch (command) {
|
||||
case 'help':
|
||||
case 'hilfe':
|
||||
await this.sendReply(roomId, event, HELP_TEXT);
|
||||
break;
|
||||
|
||||
case 'timer':
|
||||
await this.handleTimerCommand(roomId, event, userId, args);
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
case 'stopp':
|
||||
case 'pause':
|
||||
await this.handleStopCommand(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'resume':
|
||||
case 'weiter':
|
||||
await this.handleResumeCommand(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'reset':
|
||||
await this.handleResetCommand(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
await this.handleStatusCommand(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'timers':
|
||||
await this.handleTimersCommand(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'alarm':
|
||||
case 'wecker':
|
||||
await this.handleAlarmCommand(roomId, event, userId, args);
|
||||
break;
|
||||
|
||||
case 'alarms':
|
||||
case 'alarme':
|
||||
await this.handleAlarmsCommand(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'zeit':
|
||||
case 'time':
|
||||
await this.handleTimeCommand(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'weltuhr':
|
||||
await this.handleWorldClockCommand(roomId, event, userId, args);
|
||||
break;
|
||||
|
||||
case 'weltuhren':
|
||||
await this.handleWorldClocksCommand(roomId, event, userId);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Silently ignore unknown commands
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTimerCommand(roomId: string, event: any, userId: string, args: string) {
|
||||
if (!args.trim()) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'**Verwendung:** `!timer 25m` oder `!timer 1h30m`\n\nBeispiele:\n- `!timer 25` (25 Minuten)\n- `!timer 1h` (1 Stunde)\n- `!timer 90m Pomodoro` (90 Min mit Label)'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const durationSeconds = this.clockService.parseDuration(args);
|
||||
if (!durationSeconds) {
|
||||
await this.sendReply(roomId, event, 'Konnte Zeit nicht verstehen. Beispiel: `!timer 25m`');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract label if present (everything after the duration)
|
||||
const label = args.replace(/[\d\s]*[hms]+/gi, '').trim() || null;
|
||||
|
||||
try {
|
||||
const token = this.getToken(userId);
|
||||
if (!token) {
|
||||
await this.sendReply(roomId, event, 'Keine Authentifizierung. Bitte zuerst `!login`.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and start timer
|
||||
const timer = await this.clockService.createTimer(durationSeconds, label, token);
|
||||
const startedTimer = await this.clockService.startTimer(timer.id, token);
|
||||
|
||||
const durationStr = this.clockService.formatDuration(durationSeconds);
|
||||
let response = `**Timer gestartet!**\n\nDauer: ${durationStr}`;
|
||||
if (label) response += `\nLabel: ${label}`;
|
||||
response += '\n\n`!stop` zum Pausieren, `!status` fur Status';
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
} catch (error) {
|
||||
this.logger.error('Timer creation failed:', error);
|
||||
await this.sendReply(roomId, event, 'Fehler beim Erstellen des Timers.');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStopCommand(roomId: string, event: any, userId: string) {
|
||||
try {
|
||||
const token = this.getToken(userId);
|
||||
if (!token) {
|
||||
await this.sendReply(roomId, event, 'Keine Authentifizierung.');
|
||||
return;
|
||||
}
|
||||
|
||||
const runningTimer = await this.clockService.getRunningTimer(token);
|
||||
if (!runningTimer) {
|
||||
await this.sendReply(roomId, event, 'Kein laufender Timer.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (runningTimer.status === 'paused') {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'Timer ist bereits pausiert. `!resume` zum Fortsetzen.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = await this.clockService.pauseTimer(runningTimer.id, token);
|
||||
const remaining = this.clockService.formatDuration(timer.remainingSeconds);
|
||||
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
`**Timer pausiert**\n\nVerbleibend: ${remaining}\n\n\`!resume\` zum Fortsetzen, \`!reset\` zum Zurucksetzen`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Stop failed:', error);
|
||||
await this.sendReply(roomId, event, 'Fehler beim Pausieren.');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleResumeCommand(roomId: string, event: any, userId: string) {
|
||||
try {
|
||||
const token = this.getToken(userId);
|
||||
if (!token) {
|
||||
await this.sendReply(roomId, event, 'Keine Authentifizierung.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pausedTimer = await this.clockService.getRunningTimer(token);
|
||||
if (!pausedTimer || pausedTimer.status !== 'paused') {
|
||||
await this.sendReply(roomId, event, 'Kein pausierter Timer.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = await this.clockService.startTimer(pausedTimer.id, token);
|
||||
const remaining = this.clockService.formatDuration(timer.remainingSeconds);
|
||||
|
||||
await this.sendReply(roomId, event, `**Timer fortgesetzt**\n\nVerbleibend: ${remaining}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Resume failed:', error);
|
||||
await this.sendReply(roomId, event, 'Fehler beim Fortsetzen.');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleResetCommand(roomId: string, event: any, userId: string) {
|
||||
try {
|
||||
const token = this.getToken(userId);
|
||||
if (!token) {
|
||||
await this.sendReply(roomId, event, 'Keine Authentifizierung.');
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTimer = await this.clockService.getRunningTimer(token);
|
||||
if (!activeTimer) {
|
||||
await this.sendReply(roomId, event, 'Kein aktiver Timer.');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.clockService.resetTimer(activeTimer.id, token);
|
||||
await this.sendReply(roomId, event, 'Timer zuruckgesetzt.');
|
||||
} catch (error) {
|
||||
this.logger.error('Reset failed:', error);
|
||||
await this.sendReply(roomId, event, 'Fehler beim Zurucksetzen.');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStatusCommand(roomId: string, event: any, userId: string) {
|
||||
try {
|
||||
const token = this.getToken(userId);
|
||||
if (!token) {
|
||||
await this.sendReply(roomId, event, 'Keine Authentifizierung.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = await this.clockService.getRunningTimer(token);
|
||||
if (!timer) {
|
||||
await this.sendReply(roomId, event, 'Kein aktiver Timer.\n\nStarte einen mit `!timer 25m`');
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = this.clockService.formatDuration(timer.remainingSeconds);
|
||||
const total = this.clockService.formatDuration(timer.durationSeconds);
|
||||
const statusIcon = timer.status === 'running' ? '' : '';
|
||||
const statusText = timer.status === 'running' ? 'Lauft' : 'Pausiert';
|
||||
|
||||
let response = `**${statusIcon} Timer ${statusText}**\n\n`;
|
||||
response += `Verbleibend: ${remaining} / ${total}`;
|
||||
if (timer.label) response += `\nLabel: ${timer.label}`;
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
} catch (error) {
|
||||
this.logger.error('Status failed:', error);
|
||||
await this.sendReply(roomId, event, 'Fehler beim Abrufen des Status.');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTimersCommand(roomId: string, event: any, userId: string) {
|
||||
try {
|
||||
const token = this.getToken(userId);
|
||||
if (!token) {
|
||||
await this.sendReply(roomId, event, 'Keine Authentifizierung.');
|
||||
return;
|
||||
}
|
||||
|
||||
const timers = await this.clockService.getTimers(token);
|
||||
if (timers.length === 0) {
|
||||
await this.sendReply(roomId, event, 'Keine Timer.\n\nErstelle einen mit `!timer 25m`');
|
||||
return;
|
||||
}
|
||||
|
||||
let response = '**Deine Timer:**\n\n';
|
||||
timers.forEach((t, i) => {
|
||||
const duration = this.clockService.formatDuration(t.durationSeconds);
|
||||
const statusIcon = t.status === 'running' ? '' : t.status === 'paused' ? '' : '';
|
||||
const label = t.label ? ` - ${t.label}` : '';
|
||||
response += `${i + 1}. ${statusIcon} ${duration}${label}\n`;
|
||||
});
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
} catch (error) {
|
||||
this.logger.error('Timers list failed:', error);
|
||||
await this.sendReply(roomId, event, 'Fehler beim Abrufen der Timer.');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAlarmCommand(roomId: string, event: any, userId: string, args: string) {
|
||||
const parts = args.trim().split(' ');
|
||||
|
||||
// Handle !alarm off/on/delete commands
|
||||
if (parts[0] === 'off' || parts[0] === 'on' || parts[0] === 'delete') {
|
||||
// TODO: Implement alarm management
|
||||
await this.sendReply(roomId, event, 'Alarm-Verwaltung kommt bald!');
|
||||
return;
|
||||
}
|
||||
|
||||
const time = this.clockService.parseAlarmTime(args);
|
||||
if (!time) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'**Verwendung:** `!alarm 07:30` oder `!alarm 7 Uhr 30`\n\nBeispiel: `!alarm 06:00 Aufstehen!`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract label (everything after the time)
|
||||
const label = args.replace(/[\d:]+\s*(uhr\s*\d*)?/gi, '').trim() || null;
|
||||
|
||||
try {
|
||||
const token = this.getToken(userId);
|
||||
if (!token) {
|
||||
await this.sendReply(roomId, event, 'Keine Authentifizierung.');
|
||||
return;
|
||||
}
|
||||
|
||||
const alarm = await this.clockService.createAlarm(time, label, token);
|
||||
let response = `**Alarm gestellt!**\n\nZeit: ${time.substring(0, 5)} Uhr`;
|
||||
if (label) response += `\nLabel: ${label}`;
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
} catch (error) {
|
||||
this.logger.error('Alarm creation failed:', error);
|
||||
await this.sendReply(roomId, event, 'Fehler beim Erstellen des Alarms.');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAlarmsCommand(roomId: string, event: any, userId: string) {
|
||||
try {
|
||||
const token = this.getToken(userId);
|
||||
if (!token) {
|
||||
await this.sendReply(roomId, event, 'Keine Authentifizierung.');
|
||||
return;
|
||||
}
|
||||
|
||||
const alarms = await this.clockService.getAlarms(token);
|
||||
if (alarms.length === 0) {
|
||||
await this.sendReply(roomId, event, 'Keine Alarme.\n\nErstelle einen mit `!alarm 07:30`');
|
||||
return;
|
||||
}
|
||||
|
||||
let response = '**Deine Alarme:**\n\n';
|
||||
alarms.forEach((a, i) => {
|
||||
const time = a.time.substring(0, 5);
|
||||
const enabledIcon = a.enabled ? '' : '';
|
||||
const label = a.label ? ` - ${a.label}` : '';
|
||||
response += `${i + 1}. ${enabledIcon} ${time} Uhr${label}\n`;
|
||||
});
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
} catch (error) {
|
||||
this.logger.error('Alarms list failed:', error);
|
||||
await this.sendReply(roomId, event, 'Fehler beim Abrufen der Alarme.');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTimeCommand(roomId: string, event: any, userId: string) {
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
const dateStr = now.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
|
||||
let response = `**${timeStr} Uhr**\n${dateStr}`;
|
||||
|
||||
try {
|
||||
const token = this.getToken(userId);
|
||||
if (token) {
|
||||
const worldClocks = await this.clockService.getWorldClocks(token);
|
||||
if (worldClocks.length > 0) {
|
||||
response += '\n\n**Weltuhren:**';
|
||||
for (const wc of worldClocks) {
|
||||
const wcTime = new Date().toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: wc.timezone,
|
||||
});
|
||||
response += `\n${wc.cityName}: ${wcTime}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore world clock errors
|
||||
}
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleWorldClockCommand(roomId: string, event: any, userId: string, args: string) {
|
||||
if (!args.trim()) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'**Verwendung:** `!weltuhr Berlin` oder `!weltuhr New York`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await this.clockService.searchTimezones(args);
|
||||
if (results.length === 0) {
|
||||
await this.sendReply(roomId, event, `Keine Zeitzone fur "${args}" gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this.getToken(userId);
|
||||
if (!token) {
|
||||
await this.sendReply(roomId, event, 'Keine Authentifizierung.');
|
||||
return;
|
||||
}
|
||||
|
||||
const best = results[0];
|
||||
await this.clockService.addWorldClock(best.timezone, best.city, token);
|
||||
await this.sendReply(roomId, event, `**Weltuhr hinzugefugt:** ${best.city}`);
|
||||
} catch (error) {
|
||||
this.logger.error('World clock add failed:', error);
|
||||
await this.sendReply(roomId, event, 'Fehler beim Hinzufugen der Weltuhr.');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorldClocksCommand(roomId: string, event: any, userId: string) {
|
||||
try {
|
||||
const token = this.getToken(userId);
|
||||
if (!token) {
|
||||
await this.sendReply(roomId, event, 'Keine Authentifizierung.');
|
||||
return;
|
||||
}
|
||||
|
||||
const clocks = await this.clockService.getWorldClocks(token);
|
||||
if (clocks.length === 0) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'Keine Weltuhren.\n\nFuge eine hinzu mit `!weltuhr Berlin`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let response = '**Deine Weltuhren:**\n\n';
|
||||
for (const wc of clocks) {
|
||||
const time = new Date().toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: wc.timezone,
|
||||
});
|
||||
response += `${wc.cityName}: **${time}**\n`;
|
||||
}
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
} catch (error) {
|
||||
this.logger.error('World clocks list failed:', error);
|
||||
await this.sendReply(roomId, event, 'Fehler beim Abrufen der Weltuhren.');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleNaturalLanguage(roomId: string, event: any, userId: string, text: string) {
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
// Try to detect timer intent
|
||||
if (
|
||||
lower.includes('timer') ||
|
||||
lower.includes('stoppuhr') ||
|
||||
lower.match(/start\s*\d+/) ||
|
||||
lower.match(/\d+\s*(min|m|h|stunde)/)
|
||||
) {
|
||||
const duration = this.clockService.parseDuration(text);
|
||||
if (duration) {
|
||||
await this.handleTimerCommand(roomId, event, userId, text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to detect alarm intent
|
||||
if (
|
||||
lower.includes('wecker') ||
|
||||
lower.includes('alarm') ||
|
||||
lower.includes('weck mich') ||
|
||||
lower.match(/\d{1,2}:\d{2}/) ||
|
||||
lower.match(/\d{1,2}\s*uhr/)
|
||||
) {
|
||||
const time = this.clockService.parseAlarmTime(text);
|
||||
if (time) {
|
||||
await this.handleAlarmCommand(roomId, event, userId, text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No match - don't respond to random messages
|
||||
}
|
||||
|
||||
private async handleAudioMessage(roomId: string, event: any, userId: string) {
|
||||
try {
|
||||
await this.sendReply(roomId, event, 'Verarbeite Sprachnotiz...');
|
||||
|
||||
const mxcUrl = event.content.url;
|
||||
const httpUrl = this.client.mxcToHttp(mxcUrl);
|
||||
|
||||
const response = await fetch(httpUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download audio: ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const transcription = await this.transcriptionService.transcribe(buffer);
|
||||
|
||||
if (!transcription.trim()) {
|
||||
await this.sendReply(roomId, event, 'Konnte keine Sprache erkennen.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Transcription: ${transcription}`);
|
||||
|
||||
// Try to parse as command
|
||||
const lower = transcription.toLowerCase();
|
||||
|
||||
// Check for timer
|
||||
const duration = this.clockService.parseDuration(transcription);
|
||||
if (
|
||||
duration &&
|
||||
(lower.includes('timer') ||
|
||||
lower.includes('minute') ||
|
||||
lower.includes('stunde') ||
|
||||
lower.match(/\d+\s*(m|min|h)/))
|
||||
) {
|
||||
await this.sendReply(roomId, event, `"${transcription}"`);
|
||||
await this.handleTimerCommand(roomId, event, userId, transcription);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for alarm
|
||||
const time = this.clockService.parseAlarmTime(transcription);
|
||||
if (time && (lower.includes('wecker') || lower.includes('alarm') || lower.includes('uhr'))) {
|
||||
await this.sendReply(roomId, event, `"${transcription}"`);
|
||||
await this.handleAlarmCommand(roomId, event, userId, transcription);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for stop/status commands
|
||||
if (lower.includes('stop') || lower.includes('stopp') || lower.includes('pause')) {
|
||||
await this.sendReply(roomId, event, `"${transcription}"`);
|
||||
await this.handleStopCommand(roomId, event, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (lower.includes('status') || lower.includes('wie viel')) {
|
||||
await this.sendReply(roomId, event, `"${transcription}"`);
|
||||
await this.handleStatusCommand(roomId, event, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
`"${transcription}"\n\nKonnte Befehl nicht verstehen. Versuche "Timer 25 Minuten" oder "Wecker 7 Uhr".`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Audio processing failed:', error);
|
||||
await this.sendReply(roomId, event, 'Fehler bei der Sprachverarbeitung.');
|
||||
}
|
||||
}
|
||||
|
||||
private getToken(userId: string): string | null {
|
||||
// First check if user has a stored token
|
||||
const storedToken = this.clockService.getUserToken(userId);
|
||||
if (storedToken) return storedToken;
|
||||
|
||||
// Fall back to demo token for development
|
||||
return this.demoToken || null;
|
||||
}
|
||||
|
||||
private async sendWelcome(roomId: string) {
|
||||
try {
|
||||
await this.client.sendMessage(roomId, {
|
||||
msgtype: 'm.text',
|
||||
body: WELCOME_TEXT,
|
||||
format: 'org.matrix.custom.html',
|
||||
formatted_body: this.markdownToHtml(WELCOME_TEXT),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to send welcome:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendReply(roomId: string, event: any, message: string) {
|
||||
const reply = RichReply.createFor(roomId, event, message, this.markdownToHtml(message));
|
||||
reply.msgtype = 'm.text';
|
||||
await this.client.sendMessage(roomId, reply);
|
||||
}
|
||||
|
||||
private markdownToHtml(text: string): string {
|
||||
return text
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
}
|
||||
8
services/matrix-clock-bot/src/clock/clock.module.ts
Normal file
8
services/matrix-clock-bot/src/clock/clock.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ClockService } from './clock.service';
|
||||
|
||||
@Module({
|
||||
providers: [ClockService],
|
||||
exports: [ClockService],
|
||||
})
|
||||
export class ClockModule {}
|
||||
259
services/matrix-clock-bot/src/clock/clock.service.ts
Normal file
259
services/matrix-clock-bot/src/clock/clock.service.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface Timer {
|
||||
id: string;
|
||||
userId: string;
|
||||
label: string | null;
|
||||
durationSeconds: number;
|
||||
remainingSeconds: number;
|
||||
status: 'idle' | 'running' | 'paused' | 'finished';
|
||||
startedAt: string | null;
|
||||
pausedAt: string | null;
|
||||
sound: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Alarm {
|
||||
id: string;
|
||||
userId: string;
|
||||
label: string | null;
|
||||
time: string;
|
||||
enabled: boolean;
|
||||
repeatDays: number[];
|
||||
snoozeMinutes: number;
|
||||
sound: string;
|
||||
vibrate: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WorldClock {
|
||||
id: string;
|
||||
userId: string;
|
||||
timezone: string;
|
||||
cityName: string;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TimezoneResult {
|
||||
timezone: string;
|
||||
city: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClockService {
|
||||
private readonly logger = new Logger(ClockService.name);
|
||||
private readonly apiUrl: string;
|
||||
|
||||
// In-memory token storage per Matrix user
|
||||
private userTokens: Map<string, string> = new Map();
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.apiUrl = this.configService.get<string>('clock.apiUrl') || 'http://localhost:3017/api/v1';
|
||||
this.logger.log(`Clock API URL: ${this.apiUrl}`);
|
||||
}
|
||||
|
||||
setUserToken(matrixUserId: string, token: string) {
|
||||
this.userTokens.set(matrixUserId, token);
|
||||
}
|
||||
|
||||
getUserToken(matrixUserId: string): string | undefined {
|
||||
return this.userTokens.get(matrixUserId);
|
||||
}
|
||||
|
||||
private async apiCall<T>(
|
||||
endpoint: string,
|
||||
method: string = 'GET',
|
||||
token?: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiUrl}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Clock API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Timer operations
|
||||
async getTimers(token: string): Promise<Timer[]> {
|
||||
return this.apiCall<Timer[]>('/timers', 'GET', token);
|
||||
}
|
||||
|
||||
async getTimer(id: string, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>(`/timers/${id}`, 'GET', token);
|
||||
}
|
||||
|
||||
async createTimer(durationSeconds: number, label: string | null, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>('/timers', 'POST', token, {
|
||||
durationSeconds,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
async startTimer(id: string, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>(`/timers/${id}/start`, 'POST', token);
|
||||
}
|
||||
|
||||
async pauseTimer(id: string, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>(`/timers/${id}/pause`, 'POST', token);
|
||||
}
|
||||
|
||||
async resetTimer(id: string, token: string): Promise<Timer> {
|
||||
return this.apiCall<Timer>(`/timers/${id}/reset`, 'POST', token);
|
||||
}
|
||||
|
||||
async deleteTimer(id: string, token: string): Promise<void> {
|
||||
await this.apiCall<void>(`/timers/${id}`, 'DELETE', token);
|
||||
}
|
||||
|
||||
// Alarm operations
|
||||
async getAlarms(token: string): Promise<Alarm[]> {
|
||||
return this.apiCall<Alarm[]>('/alarms', 'GET', token);
|
||||
}
|
||||
|
||||
async createAlarm(time: string, label: string | null, token: string): Promise<Alarm> {
|
||||
return this.apiCall<Alarm>('/alarms', 'POST', token, {
|
||||
time,
|
||||
label,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
async toggleAlarm(id: string, token: string): Promise<Alarm> {
|
||||
return this.apiCall<Alarm>(`/alarms/${id}/toggle`, 'PATCH', token);
|
||||
}
|
||||
|
||||
async deleteAlarm(id: string, token: string): Promise<void> {
|
||||
await this.apiCall<void>(`/alarms/${id}`, 'DELETE', token);
|
||||
}
|
||||
|
||||
// World Clock operations
|
||||
async getWorldClocks(token: string): Promise<WorldClock[]> {
|
||||
return this.apiCall<WorldClock[]>('/world-clocks', 'GET', token);
|
||||
}
|
||||
|
||||
async addWorldClock(timezone: string, cityName: string, token: string): Promise<WorldClock> {
|
||||
return this.apiCall<WorldClock>('/world-clocks', 'POST', token, {
|
||||
timezone,
|
||||
cityName,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteWorldClock(id: string, token: string): Promise<void> {
|
||||
await this.apiCall<void>(`/world-clocks/${id}`, 'DELETE', token);
|
||||
}
|
||||
|
||||
// Timezone search (public, no auth needed)
|
||||
async searchTimezones(query: string): Promise<TimezoneResult[]> {
|
||||
return this.apiCall<TimezoneResult[]>(`/timezones/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
// Health check
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl.replace('/api/v1', '')}/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility: Find running timer
|
||||
async getRunningTimer(token: string): Promise<Timer | null> {
|
||||
const timers = await this.getTimers(token);
|
||||
return timers.find((t) => t.status === 'running' || t.status === 'paused') || null;
|
||||
}
|
||||
|
||||
// Utility: Parse duration string to seconds
|
||||
parseDuration(input: string): number | null {
|
||||
let totalSeconds = 0;
|
||||
|
||||
// Match hours
|
||||
const hoursMatch = input.match(/(\d+)\s*h/i);
|
||||
if (hoursMatch) {
|
||||
totalSeconds += parseInt(hoursMatch[1], 10) * 3600;
|
||||
}
|
||||
|
||||
// Match minutes
|
||||
const minutesMatch = input.match(/(\d+)\s*m(?:in)?/i);
|
||||
if (minutesMatch) {
|
||||
totalSeconds += parseInt(minutesMatch[1], 10) * 60;
|
||||
}
|
||||
|
||||
// Match seconds
|
||||
const secondsMatch = input.match(/(\d+)\s*s(?:ec)?/i);
|
||||
if (secondsMatch) {
|
||||
totalSeconds += parseInt(secondsMatch[1], 10);
|
||||
}
|
||||
|
||||
// If just a number, assume minutes
|
||||
if (totalSeconds === 0) {
|
||||
const justNumber = input.match(/^(\d+)$/);
|
||||
if (justNumber) {
|
||||
totalSeconds = parseInt(justNumber[1], 10) * 60;
|
||||
}
|
||||
}
|
||||
|
||||
return totalSeconds > 0 ? totalSeconds : null;
|
||||
}
|
||||
|
||||
// Utility: Parse time string to HH:MM:SS
|
||||
parseAlarmTime(input: string): string | null {
|
||||
// Try HH:MM format
|
||||
let match = input.match(/(\d{1,2}):(\d{2})(?::(\d{2}))?/);
|
||||
if (match) {
|
||||
const hours = parseInt(match[1], 10);
|
||||
const minutes = parseInt(match[2], 10);
|
||||
const seconds = match[3] ? parseInt(match[3], 10) : 0;
|
||||
|
||||
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Try "X Uhr Y" format (German)
|
||||
match = input.match(/(\d{1,2})\s*uhr(?:\s*(\d{1,2}))?/i);
|
||||
if (match) {
|
||||
const hours = parseInt(match[1], 10);
|
||||
const minutes = match[2] ? parseInt(match[2], 10) : 0;
|
||||
|
||||
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Utility: Format seconds to human readable
|
||||
formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
72
services/matrix-clock-bot/src/config/configuration.ts
Normal file
72
services/matrix-clock-bot/src/config/configuration.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
export default () => ({
|
||||
port: parseInt(process.env.PORT || '3317', 10),
|
||||
matrix: {
|
||||
homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008',
|
||||
accessToken: process.env.MATRIX_ACCESS_TOKEN || '',
|
||||
allowedRooms: (process.env.MATRIX_ALLOWED_ROOMS || '').split(',').filter(Boolean),
|
||||
storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json',
|
||||
},
|
||||
clock: {
|
||||
apiUrl: process.env.CLOCK_API_URL || 'http://localhost:3017/api/v1',
|
||||
},
|
||||
stt: {
|
||||
url: process.env.STT_URL || 'http://localhost:3020',
|
||||
},
|
||||
});
|
||||
|
||||
export const HELP_TEXT = `**Clock Bot - Zeiterfassung per Chat**
|
||||
|
||||
**Timer (Stoppuhr):**
|
||||
- \`!timer 25m\` oder \`!timer 1h30m\` - Timer erstellen & starten
|
||||
- \`!stop\` - Laufenden Timer pausieren
|
||||
- \`!resume\` - Pausierten Timer fortsetzen
|
||||
- \`!reset\` - Timer zurucksetzen
|
||||
- \`!status\` - Aktuellen Timer-Status anzeigen
|
||||
- \`!timers\` - Alle Timer anzeigen
|
||||
|
||||
**Alarme (Wecker):**
|
||||
- \`!alarm 07:30\` - Alarm fur 7:30 Uhr setzen
|
||||
- \`!alarm 07:30 Aufwachen!\` - Alarm mit Label
|
||||
- \`!alarms\` - Alle Alarme anzeigen
|
||||
- \`!alarm off 1\` - Alarm #1 deaktivieren
|
||||
- \`!alarm on 1\` - Alarm #1 aktivieren
|
||||
- \`!alarm delete 1\` - Alarm #1 loschen
|
||||
|
||||
**Weltuhren:**
|
||||
- \`!zeit\` oder \`!time\` - Aktuelle Zeit + Weltuhren
|
||||
- \`!weltuhr Berlin\` - Weltuhr hinzufugen
|
||||
- \`!weltuhren\` - Alle Weltuhren anzeigen
|
||||
|
||||
**Sprachnotizen:**
|
||||
Sende eine Sprachnotiz wie "Timer 25 Minuten" oder "Wecker um 7 Uhr"
|
||||
|
||||
**Shortcuts:**
|
||||
- "start 25 min" - Timer starten
|
||||
- "stop" - Timer stoppen
|
||||
- "status" - Status anzeigen`;
|
||||
|
||||
export const WELCOME_TEXT = `**Clock Bot - Zeiterfassung**
|
||||
|
||||
Starte mit:
|
||||
- \`!timer 25m\` - 25-Minuten Timer
|
||||
- \`!alarm 07:30\` - Wecker stellen
|
||||
- \`!zeit\` - Aktuelle Zeit
|
||||
|
||||
Oder sende eine Sprachnotiz!
|
||||
|
||||
\`!help\` fur alle Befehle.`;
|
||||
|
||||
// Natural language patterns for time parsing
|
||||
export const TIME_PATTERNS = {
|
||||
// Timer duration patterns
|
||||
duration: [
|
||||
/(\d+)\s*h(?:ours?|r)?(?:\s*(\d+)\s*m(?:in(?:utes?)?)?)?/i, // 1h, 1h30m, 1 hour 30 minutes
|
||||
/(\d+)\s*m(?:in(?:utes?)?)?/i, // 25m, 25 min, 25 minutes
|
||||
/(\d+)\s*s(?:ec(?:onds?)?)?/i, // 30s, 30 sec
|
||||
],
|
||||
// Alarm time patterns
|
||||
alarmTime: [
|
||||
/(\d{1,2}):(\d{2})(?::(\d{2}))?/, // 07:30, 7:30:00
|
||||
/(\d{1,2})\s*uhr(?:\s*(\d{1,2}))?/i, // 7 Uhr, 7 Uhr 30
|
||||
],
|
||||
};
|
||||
9
services/matrix-clock-bot/src/health.controller.ts
Normal file
9
services/matrix-clock-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-clock-bot' };
|
||||
}
|
||||
}
|
||||
15
services/matrix-clock-bot/src/main.ts
Normal file
15
services/matrix-clock-bot/src/main.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const port = process.env.PORT || 3317;
|
||||
|
||||
await app.listen(port);
|
||||
|
||||
const logger = new Logger('Bootstrap');
|
||||
logger.log(`Matrix Clock Bot running on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TranscriptionService } from './transcription.service';
|
||||
|
||||
@Module({
|
||||
providers: [TranscriptionService],
|
||||
exports: [TranscriptionService],
|
||||
})
|
||||
export class TranscriptionModule {}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
interface SttResponse {
|
||||
text: string;
|
||||
language?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TranscriptionService {
|
||||
private readonly logger = new Logger(TranscriptionService.name);
|
||||
private readonly sttUrl: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.sttUrl = this.configService.get<string>('stt.url') || 'http://localhost:3020';
|
||||
this.logger.log(`STT Service URL: ${this.sttUrl}`);
|
||||
}
|
||||
|
||||
async transcribe(audioBuffer: Buffer, language: string = 'de'): Promise<string> {
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/ogg' });
|
||||
formData.append('file', blob, 'audio.ogg');
|
||||
formData.append('language', language);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.sttUrl}/transcribe`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`STT service error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const result: SttResponse = await response.json();
|
||||
this.logger.log(`Transcription completed: ${result.text.substring(0, 50)}...`);
|
||||
return result.text;
|
||||
} catch (error) {
|
||||
this.logger.error('Transcription failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.sttUrl}/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
services/matrix-clock-bot/tsconfig.json
Normal file
22
services/matrix-clock-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": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue