mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
feat(matrix): add Matrix Todo Bot service
GDPR-compliant task management bot for Matrix with: - Task CRUD: !add, !list, !done, !delete - Priority support: !p1 to !p4 - Date shortcuts: @heute, @morgen, @übermorgen - Project tags: #projektname - Natural language keywords: hilfe, zeige aufgaben, heute - Welcome messages and auto-pin help on room join - Per-user task isolation via Matrix user ID - Local JSON storage Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3389252d3a
commit
2c341b5328
16 changed files with 1257 additions and 0 deletions
|
|
@ -975,6 +975,35 @@ services:
|
|||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Matrix Todo Bot (GDPR-compliant Task Management)
|
||||
# ============================================
|
||||
|
||||
matrix-todo-bot:
|
||||
image: matrix-todo-bot:latest
|
||||
container_name: manacore-matrix-todo-bot
|
||||
restart: always
|
||||
depends_on:
|
||||
synapse:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3314
|
||||
TZ: Europe/Berlin
|
||||
MATRIX_HOMESERVER_URL: http://synapse:8008
|
||||
MATRIX_ACCESS_TOKEN: ${MATRIX_TODO_BOT_TOKEN}
|
||||
MATRIX_ALLOWED_ROOMS: ${MATRIX_TODO_BOT_ROOMS:-}
|
||||
volumes:
|
||||
- matrix_todo_bot_data:/app/data
|
||||
ports:
|
||||
- "3314:3314"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3314/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Auto-Update (Watchtower)
|
||||
# ============================================
|
||||
|
|
@ -1023,3 +1052,5 @@ volumes:
|
|||
name: manacore-matrix-stats-bot
|
||||
matrix_project_doc_bot_data:
|
||||
name: manacore-matrix-project-doc-bot
|
||||
matrix_todo_bot_data:
|
||||
name: manacore-matrix-todo-bot
|
||||
|
|
|
|||
6
services/matrix-todo-bot/.dockerignore
Normal file
6
services/matrix-todo-bot/.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
.env*
|
||||
data
|
||||
161
services/matrix-todo-bot/CLAUDE.md
Normal file
161
services/matrix-todo-bot/CLAUDE.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# Matrix Todo Bot - Claude Code Guidelines
|
||||
|
||||
## Overview
|
||||
|
||||
Matrix Todo Bot provides a GDPR-compliant task management interface via Matrix chat. It uses the Matrix protocol for messaging, allowing self-hosting all data on the Mac Mini server.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: NestJS 10
|
||||
- **Matrix**: matrix-bot-sdk
|
||||
- **Storage**: Local JSON file (per-user tasks)
|
||||
|
||||
## 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-todo-bot/
|
||||
├── src/
|
||||
│ ├── main.ts # Application entry point
|
||||
│ ├── app.module.ts # Root module
|
||||
│ ├── health.controller.ts # Health check endpoint
|
||||
│ ├── config/
|
||||
│ │ └── configuration.ts # Configuration & help texts
|
||||
│ ├── bot/
|
||||
│ │ ├── bot.module.ts
|
||||
│ │ └── matrix.service.ts # Matrix client & command handlers
|
||||
│ └── todo/
|
||||
│ ├── todo.module.ts
|
||||
│ └── todo.service.ts # Task storage & management
|
||||
├── Dockerfile
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Matrix Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!help` | Show help message |
|
||||
| `!add [task]` | Create a new task |
|
||||
| `!list` | Show all pending tasks |
|
||||
| `!heute` / `!today` | Show today's tasks |
|
||||
| `!inbox` | Show tasks without date |
|
||||
| `!done [nr]` | Mark task as complete |
|
||||
| `!delete [nr]` | Delete a task |
|
||||
| `!projects` | List all projects |
|
||||
| `!project [name]` | Show project tasks |
|
||||
| `!status` | Show bot status |
|
||||
| `!pin` | Pin help to room |
|
||||
|
||||
## Natural Language Keywords
|
||||
|
||||
The bot also responds to natural language (German + English):
|
||||
- "hilfe", "help" → Show help
|
||||
- "zeige aufgaben", "show tasks" → List tasks
|
||||
- "heute", "today" → Today's tasks
|
||||
- "inbox", "eingang" → Inbox tasks
|
||||
- "projekte", "projects" → List projects
|
||||
|
||||
## Task Input Syntax
|
||||
|
||||
```
|
||||
!add Task title !p1 @morgen #projektname
|
||||
│ │ │ └── Project
|
||||
│ │ └── Due date (@heute, @morgen, @übermorgen)
|
||||
│ └── Priority (1-4, 1 highest)
|
||||
└── Task title
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# Server
|
||||
PORT=3314
|
||||
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER_URL=http://localhost:8008
|
||||
MATRIX_ACCESS_TOKEN=syt_xxx
|
||||
MATRIX_ALLOWED_ROOMS=#todo-bot:mana.how
|
||||
MATRIX_STORAGE_PATH=./data/bot-storage.json
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Build locally
|
||||
docker build -f services/matrix-todo-bot/Dockerfile -t matrix-todo-bot services/matrix-todo-bot
|
||||
|
||||
# Run
|
||||
docker run -p 3314:3314 \
|
||||
-e MATRIX_HOMESERVER_URL=http://synapse:8008 \
|
||||
-e MATRIX_ACCESS_TOKEN=syt_xxx \
|
||||
-v matrix-todo-bot-data:/app/data \
|
||||
matrix-todo-bot
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:3314/health
|
||||
```
|
||||
|
||||
## Getting a Matrix Access Token
|
||||
|
||||
```bash
|
||||
# Login to get access token
|
||||
curl -X POST "https://matrix.mana.how/_matrix/client/v3/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "m.login.password",
|
||||
"user": "todo-bot",
|
||||
"password": "your-password"
|
||||
}'
|
||||
|
||||
# Response contains: {"access_token": "syt_xxx", ...}
|
||||
```
|
||||
|
||||
## Data Storage
|
||||
|
||||
Tasks are stored in a local JSON file (`/app/data/todo-data.json`) with per-user isolation.
|
||||
|
||||
Structure:
|
||||
```json
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"id": "unique-id",
|
||||
"title": "Task title",
|
||||
"completed": false,
|
||||
"priority": 4,
|
||||
"dueDate": "2024-01-28",
|
||||
"project": "Arbeit",
|
||||
"labels": [],
|
||||
"createdAt": "2024-01-27T10:00:00Z",
|
||||
"completedAt": null,
|
||||
"userId": "@user:mana.how"
|
||||
}
|
||||
],
|
||||
"projects": []
|
||||
}
|
||||
```
|
||||
|
||||
## GDPR Compliance
|
||||
|
||||
- All task data stored locally on Mac Mini
|
||||
- No third-party data processing
|
||||
- Full control over data retention
|
||||
- Per-user data isolation via Matrix user IDs
|
||||
- Can delete all user data on request
|
||||
48
services/matrix-todo-bot/Dockerfile
Normal file
48
services/matrix-todo-bot/Dockerfile
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json ./
|
||||
|
||||
# Install all dependencies (including devDependencies for build)
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build TypeScript
|
||||
RUN rm -rf dist && npx tsc -p tsconfig.build.json
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create data directory for storage
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Copy package files
|
||||
COPY package.json ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nestjs -u 1001 && \
|
||||
chown -R nestjs:nodejs /app
|
||||
|
||||
USER nestjs
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3314/health || exit 1
|
||||
|
||||
EXPOSE 3314
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
8
services/matrix-todo-bot/nest-cli.json
Normal file
8
services/matrix-todo-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
44
services/matrix-todo-bot/package.json
Normal file
44
services/matrix-todo-bot/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@manacore/matrix-todo-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Matrix bot for task management - GDPR compliant",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"pnpm": {
|
||||
"neverBuiltDependencies": [
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs"
|
||||
],
|
||||
"overrides": {
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs": "npm:empty-npm-package@1.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "rm -rf dist || true",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"matrix-bot-sdk": "^0.7.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/node": "^22.10.5",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
19
services/matrix-todo-bot/src/app.module.ts
Normal file
19
services/matrix-todo-bot/src/app.module.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import configuration from './config/configuration';
|
||||
import { BotModule } from './bot/bot.module';
|
||||
import { TodoModule } from './todo/todo.module';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
}),
|
||||
BotModule,
|
||||
TodoModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
10
services/matrix-todo-bot/src/bot/bot.module.ts
Normal file
10
services/matrix-todo-bot/src/bot/bot.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MatrixService } from './matrix.service';
|
||||
import { TodoModule } from '../todo/todo.module';
|
||||
|
||||
@Module({
|
||||
imports: [TodoModule],
|
||||
providers: [MatrixService],
|
||||
exports: [MatrixService],
|
||||
})
|
||||
export class BotModule {}
|
||||
557
services/matrix-todo-bot/src/bot/matrix.service.ts
Normal file
557
services/matrix-todo-bot/src/bot/matrix.service.ts
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
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 { TodoService, Task } from '../todo/todo.service';
|
||||
import { HELP_TEXT, WELCOME_TEXT, BOT_INTRODUCTION } from '../config/configuration';
|
||||
|
||||
// Natural language keywords that trigger commands (German + English)
|
||||
const KEYWORD_COMMANDS: { keywords: string[]; command: string }[] = [
|
||||
{ keywords: ['hilfe', 'help', 'was kannst du', 'befehle', 'commands'], command: 'help' },
|
||||
{
|
||||
keywords: ['zeige aufgaben', 'meine aufgaben', 'was muss ich', 'show tasks', 'list'],
|
||||
command: 'list',
|
||||
},
|
||||
{ keywords: ['heute', 'today', 'was steht an'], command: 'today' },
|
||||
{ keywords: ['inbox', 'eingang', 'ohne datum'], command: 'inbox' },
|
||||
{ keywords: ['projekte', 'projects'], command: 'projects' },
|
||||
{ keywords: ['status', 'verbindung', 'connection'], command: 'status' },
|
||||
];
|
||||
|
||||
@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;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private todoService: TodoService
|
||||
) {
|
||||
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 {
|
||||
// Ensure storage directory exists
|
||||
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);
|
||||
|
||||
// Auto-join rooms when invited
|
||||
AutojoinRoomsMixin.setupOnClient(this.client);
|
||||
|
||||
// Handle room invites with introduction
|
||||
this.client.on('room.invite', async (roomId: string) => {
|
||||
this.logger.log(`Invited to room ${roomId}, joining...`);
|
||||
await this.client.joinRoom(roomId);
|
||||
|
||||
// Send introduction after a short delay
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await this.sendBotIntroduction(roomId);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send introduction to ${roomId}:`, error);
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Handle member joins for welcome message
|
||||
this.client.on('room.event', async (roomId: string, event: any) => {
|
||||
if (event.type === 'm.room.member' && event.content?.membership === 'join') {
|
||||
const userId = event.state_key;
|
||||
const botUserId = await this.client.getUserId();
|
||||
|
||||
// Don't welcome the bot itself
|
||||
if (userId === botUserId) return;
|
||||
|
||||
// Check if this is a new join (not just profile update)
|
||||
if (event.unsigned?.prev_content?.membership !== 'join') {
|
||||
await this.sendWelcomeMessage(roomId, userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set up message handler
|
||||
this.client.on('room.message', async (roomId: string, event: any) => {
|
||||
await this.handleMessage(roomId, event);
|
||||
});
|
||||
|
||||
await this.client.start();
|
||||
this.logger.log(`Matrix Todo Bot connected to ${this.homeserverUrl}`);
|
||||
|
||||
const userId = await this.client.getUserId();
|
||||
this.logger.log(`Bot user ID: ${userId}`);
|
||||
|
||||
if (this.allowedRooms.length > 0) {
|
||||
this.logger.log(`Allowed rooms: ${this.allowedRooms.join(', ')}`);
|
||||
} else {
|
||||
this.logger.log('No room restrictions - bot will respond in all rooms');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize Matrix client:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage(roomId: string, event: any) {
|
||||
// Ignore messages from the bot itself
|
||||
const botUserId = await this.client.getUserId();
|
||||
if (event.sender === botUserId) return;
|
||||
|
||||
// Check if room is allowed
|
||||
if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) {
|
||||
this.logger.debug(`Ignoring message from non-allowed room: ${roomId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle text messages
|
||||
if (event.content?.msgtype !== 'm.text') return;
|
||||
|
||||
const body = event.content.body?.trim();
|
||||
if (!body) return;
|
||||
|
||||
const userId = event.sender;
|
||||
|
||||
try {
|
||||
// Check for natural language keywords first
|
||||
const keywordCommand = this.detectKeywordCommand(body);
|
||||
if (keywordCommand) {
|
||||
await this.executeCommand(roomId, event, userId, keywordCommand, '');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for ! commands
|
||||
if (body.startsWith('!')) {
|
||||
const [command, ...args] = body.slice(1).split(' ');
|
||||
await this.executeCommand(roomId, event, userId, command.toLowerCase(), args.join(' '));
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error handling message: ${error}`);
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Ein Fehler ist aufgetreten. Bitte versuche es erneut.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private detectKeywordCommand(message: string): string | null {
|
||||
const lowerMessage = message.toLowerCase().trim();
|
||||
|
||||
// Only check short messages for keywords
|
||||
if (lowerMessage.length > 50) return null;
|
||||
|
||||
for (const { keywords, command } of KEYWORD_COMMANDS) {
|
||||
for (const keyword of keywords) {
|
||||
if (
|
||||
lowerMessage === keyword ||
|
||||
lowerMessage.startsWith(keyword + ' ') ||
|
||||
lowerMessage.includes(keyword)
|
||||
) {
|
||||
this.logger.log(`Detected keyword "${keyword}" -> command "${command}"`);
|
||||
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 'add':
|
||||
case 'neu':
|
||||
case 'neue':
|
||||
await this.handleAddTask(roomId, event, userId, args);
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
case 'liste':
|
||||
case 'alle':
|
||||
await this.handleListTasks(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'today':
|
||||
case 'heute':
|
||||
await this.handleTodayTasks(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'inbox':
|
||||
case 'eingang':
|
||||
await this.handleInboxTasks(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
case 'erledigt':
|
||||
case 'fertig':
|
||||
await this.handleCompleteTask(roomId, event, userId, args);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
case 'löschen':
|
||||
case 'entfernen':
|
||||
await this.handleDeleteTask(roomId, event, userId, args);
|
||||
break;
|
||||
|
||||
case 'projects':
|
||||
case 'projekte':
|
||||
await this.handleProjects(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'project':
|
||||
case 'projekt':
|
||||
await this.handleProjectTasks(roomId, event, userId, args);
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
await this.handleStatus(roomId, event, userId);
|
||||
break;
|
||||
|
||||
case 'pin':
|
||||
await this.handlePinHelp(roomId, event);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown command - ignore silently or send help
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAddTask(roomId: string, event: any, userId: string, input: string) {
|
||||
if (!input.trim()) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Bitte gib eine Aufgabe an.\n\nBeispiel: `!add Einkaufen gehen`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, priority, dueDate, project } = this.todoService.parseTaskInput(input);
|
||||
|
||||
const task = await this.todoService.createTask(userId, title, {
|
||||
priority,
|
||||
dueDate,
|
||||
project,
|
||||
});
|
||||
|
||||
let response = `✅ Aufgabe erstellt: **${task.title}**`;
|
||||
|
||||
const details: string[] = [];
|
||||
if (priority < 4) details.push(`Priorität ${priority}`);
|
||||
if (dueDate) details.push(`Datum: ${this.formatDate(dueDate)}`);
|
||||
if (project) details.push(`Projekt: ${project}`);
|
||||
|
||||
if (details.length > 0) {
|
||||
response += `\n📋 ${details.join(' | ')}`;
|
||||
}
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleListTasks(roomId: string, event: any, userId: string) {
|
||||
const tasks = await this.todoService.getAllPendingTasks(userId);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'📭 Keine offenen Aufgaben.\n\nErstelle eine mit `!add [Aufgabe]`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = this.formatTaskList('📋 **Alle offenen Aufgaben:**', tasks);
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleTodayTasks(roomId: string, event: any, userId: string) {
|
||||
const tasks = await this.todoService.getTodayTasks(userId);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'📭 Keine Aufgaben für heute.\n\nErstelle eine mit `!add Aufgabe @heute`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = this.formatTaskList('📅 **Aufgaben für heute:**', tasks);
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleInboxTasks(roomId: string, event: any, userId: string) {
|
||||
const tasks = await this.todoService.getInboxTasks(userId);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
await this.sendReply(roomId, event, '📭 Inbox ist leer.\n\nAufgaben ohne Datum landen hier.');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = this.formatTaskList('📥 **Inbox (ohne Datum):**', tasks);
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleCompleteTask(roomId: string, event: any, userId: string, args: string) {
|
||||
const taskNumber = parseInt(args.trim());
|
||||
|
||||
if (isNaN(taskNumber) || taskNumber < 1) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Bitte gib eine gültige Aufgabennummer an.\n\nBeispiel: `!done 1`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const task = await this.todoService.completeTask(userId, taskNumber);
|
||||
|
||||
if (!task) {
|
||||
await this.sendReply(roomId, event, `❌ Aufgabe #${taskNumber} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendReply(roomId, event, `✅ Erledigt: ~~${task.title}~~`);
|
||||
}
|
||||
|
||||
private async handleDeleteTask(roomId: string, event: any, userId: string, args: string) {
|
||||
const taskNumber = parseInt(args.trim());
|
||||
|
||||
if (isNaN(taskNumber) || taskNumber < 1) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Bitte gib eine gültige Aufgabennummer an.\n\nBeispiel: `!delete 1`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const task = await this.todoService.deleteTask(userId, taskNumber);
|
||||
|
||||
if (!task) {
|
||||
await this.sendReply(roomId, event, `❌ Aufgabe #${taskNumber} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendReply(roomId, event, `🗑️ Gelöscht: ${task.title}`);
|
||||
}
|
||||
|
||||
private async handleProjects(roomId: string, event: any, userId: string) {
|
||||
const projects = await this.todoService.getProjects(userId);
|
||||
|
||||
if (projects.length === 0) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'📭 Keine Projekte.\n\nErstelle eine Aufgabe mit Projekt: `!add Aufgabe #projektname`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let response = '📁 **Deine Projekte:**\n\n';
|
||||
for (const project of projects) {
|
||||
response += `• ${project.name}\n`;
|
||||
}
|
||||
response += '\nZeige Projektaufgaben mit `!project [Name]`';
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleProjectTasks(roomId: string, event: any, userId: string, args: string) {
|
||||
const projectName = args.trim();
|
||||
|
||||
if (!projectName) {
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Bitte gib einen Projektnamen an.\n\nBeispiel: `!project Arbeit`'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = await this.todoService.getProjectTasks(userId, projectName);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
await this.sendReply(roomId, event, `📭 Keine Aufgaben im Projekt "${projectName}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = this.formatTaskList(`📁 **Projekt: ${projectName}**`, tasks);
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handleStatus(roomId: string, event: any, userId: string) {
|
||||
const stats = await this.todoService.getStats(userId);
|
||||
|
||||
const response = `📊 **Status**
|
||||
|
||||
• Offene Aufgaben: ${stats.pending}
|
||||
• Heute fällig: ${stats.today}
|
||||
• Erledigt: ${stats.completed}
|
||||
• Gesamt: ${stats.total}
|
||||
|
||||
Bot: ✅ Online`;
|
||||
|
||||
await this.sendReply(roomId, event, response);
|
||||
}
|
||||
|
||||
private async handlePinHelp(roomId: string, event: any) {
|
||||
try {
|
||||
// Send help message
|
||||
const helpEventId = await this.client.sendMessage(roomId, {
|
||||
msgtype: 'm.text',
|
||||
body: HELP_TEXT,
|
||||
format: 'org.matrix.custom.html',
|
||||
formatted_body: this.markdownToHtml(HELP_TEXT),
|
||||
});
|
||||
|
||||
// Pin it
|
||||
await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', {
|
||||
pinned: [helpEventId],
|
||||
});
|
||||
|
||||
await this.sendReply(roomId, event, '📌 Hilfe wurde angepinnt!');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to pin help:', error);
|
||||
await this.sendReply(
|
||||
roomId,
|
||||
event,
|
||||
'❌ Konnte Hilfe nicht anpinnen (fehlende Berechtigung?)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private formatTaskList(header: string, tasks: Task[]): string {
|
||||
let response = `${header}\n\n`;
|
||||
|
||||
tasks.forEach((task, index) => {
|
||||
const num = index + 1;
|
||||
const priority = task.priority < 4 ? `❗`.repeat(4 - task.priority) : '';
|
||||
const date = task.dueDate ? ` 📅 ${this.formatDate(task.dueDate)}` : '';
|
||||
const project = task.project ? ` 📁 ${task.project}` : '';
|
||||
|
||||
response += `**${num}.** ${task.title}${priority}${date}${project}\n`;
|
||||
});
|
||||
|
||||
response += `\n✅ Erledigen: \`!done [Nr]\` | 🗑️ Löschen: \`!delete [Nr]\``;
|
||||
return response;
|
||||
}
|
||||
|
||||
private formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
if (dateStr === today.toISOString().split('T')[0]) {
|
||||
return 'Heute';
|
||||
} else if (dateStr === tomorrow.toISOString().split('T')[0]) {
|
||||
return 'Morgen';
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
|
||||
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 async sendWelcomeMessage(roomId: string, userId: string) {
|
||||
try {
|
||||
await this.client.sendMessage(roomId, {
|
||||
msgtype: 'm.text',
|
||||
body: WELCOME_TEXT,
|
||||
format: 'org.matrix.custom.html',
|
||||
formatted_body: this.markdownToHtml(WELCOME_TEXT),
|
||||
});
|
||||
this.logger.log(`Sent welcome message to ${userId} in ${roomId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send welcome message: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendBotIntroduction(roomId: string) {
|
||||
await this.client.sendMessage(roomId, {
|
||||
msgtype: 'm.text',
|
||||
body: BOT_INTRODUCTION,
|
||||
format: 'org.matrix.custom.html',
|
||||
formatted_body: this.markdownToHtml(BOT_INTRODUCTION),
|
||||
});
|
||||
|
||||
// Try to pin the help message
|
||||
try {
|
||||
const helpEventId = await this.client.sendMessage(roomId, {
|
||||
msgtype: 'm.text',
|
||||
body: HELP_TEXT,
|
||||
format: 'org.matrix.custom.html',
|
||||
formatted_body: this.markdownToHtml(HELP_TEXT),
|
||||
});
|
||||
|
||||
await this.client.sendStateEvent(roomId, 'm.room.pinned_events', '', {
|
||||
pinned: [helpEventId],
|
||||
});
|
||||
this.logger.log(`Pinned help message in ${roomId}`);
|
||||
} catch (error) {
|
||||
this.logger.debug(`Could not pin help (might lack permissions): ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private markdownToHtml(text: string): string {
|
||||
return text
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/~~(.+?)~~/g, '<del>$1</del>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
}
|
||||
58
services/matrix-todo-bot/src/config/configuration.ts
Normal file
58
services/matrix-todo-bot/src/config/configuration.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export default () => ({
|
||||
port: parseInt(process.env.PORT || '3314', 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',
|
||||
},
|
||||
todo: {
|
||||
apiUrl: process.env.TODO_API_URL || 'http://localhost:3010/api/v1',
|
||||
serviceKey: process.env.TODO_SERVICE_KEY || '',
|
||||
},
|
||||
});
|
||||
|
||||
export const HELP_TEXT = `🎯 **Todo Bot - Hilfe**
|
||||
|
||||
**Aufgaben verwalten:**
|
||||
• \`!add [Aufgabe]\` - Neue Aufgabe hinzufügen
|
||||
• \`!list\` oder \`!heute\` - Heutige Aufgaben anzeigen
|
||||
• \`!inbox\` - Aufgaben ohne Datum anzeigen
|
||||
• \`!done [Nr]\` - Aufgabe als erledigt markieren
|
||||
• \`!delete [Nr]\` - Aufgabe löschen
|
||||
|
||||
**Projekte:**
|
||||
• \`!projects\` - Alle Projekte anzeigen
|
||||
• \`!project [Name]\` - Aufgaben eines Projekts anzeigen
|
||||
|
||||
**Prioritäten:**
|
||||
• \`!add Wichtige Aufgabe !p1\` - Höchste Priorität (1-4)
|
||||
• \`!add Morgen machen @morgen\` - Datum setzen
|
||||
|
||||
**Sonstiges:**
|
||||
• \`!status\` - Verbindungsstatus prüfen
|
||||
• \`!help\` oder \`hilfe\` - Diese Hilfe anzeigen
|
||||
|
||||
**Natürliche Sprache:**
|
||||
Du kannst auch einfach "hilfe", "zeige aufgaben", "was muss ich heute machen?" schreiben.`;
|
||||
|
||||
export const WELCOME_TEXT = `👋 **Willkommen beim Todo Bot!**
|
||||
|
||||
Ich helfe dir, deine Aufgaben zu verwalten. Hier sind die wichtigsten Befehle:
|
||||
|
||||
• \`!add [Aufgabe]\` - Neue Aufgabe erstellen
|
||||
• \`!list\` - Heutige Aufgaben anzeigen
|
||||
• \`!done [Nr]\` - Aufgabe abhaken
|
||||
|
||||
Schreibe \`!help\` oder einfach "hilfe" für alle Befehle.`;
|
||||
|
||||
export const BOT_INTRODUCTION = `🎯 **Hallo! Ich bin der Todo Bot.**
|
||||
|
||||
Ich bin jetzt diesem Raum beigetreten und kann dir bei der Aufgabenverwaltung helfen.
|
||||
|
||||
**Schnellstart:**
|
||||
• \`!add Einkaufen gehen\` - Aufgabe erstellen
|
||||
• \`!list\` - Deine Aufgaben sehen
|
||||
• \`!done 1\` - Erste Aufgabe abhaken
|
||||
|
||||
Schreibe \`!help\` für alle Befehle!`;
|
||||
13
services/matrix-todo-bot/src/health.controller.ts
Normal file
13
services/matrix-todo-bot/src/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'matrix-todo-bot',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
17
services/matrix-todo-bot/src/main.ts
Normal file
17
services/matrix-todo-bot/src/main.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>('port', 3314);
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`Todo Bot is running on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
8
services/matrix-todo-bot/src/todo/todo.module.ts
Normal file
8
services/matrix-todo-bot/src/todo/todo.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TodoService } from './todo.service';
|
||||
|
||||
@Module({
|
||||
providers: [TodoService],
|
||||
exports: [TodoService],
|
||||
})
|
||||
export class TodoModule {}
|
||||
251
services/matrix-todo-bot/src/todo/todo.service.ts
Normal file
251
services/matrix-todo-bot/src/todo/todo.service.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
priority: number; // 1-4, 1 is highest
|
||||
dueDate: string | null; // ISO date string
|
||||
project: string | null;
|
||||
labels: string[];
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
userId: string; // Matrix user ID
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface TodoData {
|
||||
tasks: Task[];
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TodoService implements OnModuleInit {
|
||||
private readonly logger = new Logger(TodoService.name);
|
||||
private data: TodoData = { tasks: [], projects: [] };
|
||||
private dataPath: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const storagePath = this.configService.get<string>(
|
||||
'matrix.storagePath',
|
||||
'./data/bot-storage.json'
|
||||
);
|
||||
this.dataPath = storagePath.replace('bot-storage.json', 'todo-data.json');
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
try {
|
||||
const dir = path.dirname(this.dataPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(this.dataPath)) {
|
||||
const content = fs.readFileSync(this.dataPath, 'utf-8');
|
||||
this.data = JSON.parse(content);
|
||||
this.logger.log(
|
||||
`Loaded ${this.data.tasks.length} tasks, ${this.data.projects.length} projects`
|
||||
);
|
||||
} else {
|
||||
this.data = { tasks: [], projects: [] };
|
||||
await this.saveData();
|
||||
this.logger.log('Created new todo data file');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to load todo data:', error);
|
||||
this.data = { tasks: [], projects: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private async saveData(): Promise<void> {
|
||||
try {
|
||||
fs.writeFileSync(this.dataPath, JSON.stringify(this.data, null, 2));
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to save todo data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
// Task operations
|
||||
|
||||
async createTask(userId: string, title: string, options?: Partial<Task>): Promise<Task> {
|
||||
const task: Task = {
|
||||
id: this.generateId(),
|
||||
title,
|
||||
completed: false,
|
||||
priority: options?.priority || 4,
|
||||
dueDate: options?.dueDate || null,
|
||||
project: options?.project || null,
|
||||
labels: options?.labels || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
userId,
|
||||
};
|
||||
|
||||
this.data.tasks.push(task);
|
||||
await this.saveData();
|
||||
this.logger.log(`Created task "${title}" for user ${userId}`);
|
||||
return task;
|
||||
}
|
||||
|
||||
async getTodayTasks(userId: string): Promise<Task[]> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return this.data.tasks
|
||||
.filter(
|
||||
(t) => t.userId === userId && !t.completed && t.dueDate && t.dueDate.startsWith(today)
|
||||
)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
async getInboxTasks(userId: string): Promise<Task[]> {
|
||||
return this.data.tasks
|
||||
.filter((t) => t.userId === userId && !t.completed && !t.dueDate && !t.project)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
async getAllPendingTasks(userId: string): Promise<Task[]> {
|
||||
return this.data.tasks
|
||||
.filter((t) => t.userId === userId && !t.completed)
|
||||
.sort((a, b) => {
|
||||
// Sort by due date first (nulls last), then by priority
|
||||
if (a.dueDate && !b.dueDate) return -1;
|
||||
if (!a.dueDate && b.dueDate) return 1;
|
||||
if (a.dueDate && b.dueDate) {
|
||||
const dateCompare = a.dueDate.localeCompare(b.dueDate);
|
||||
if (dateCompare !== 0) return dateCompare;
|
||||
}
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
}
|
||||
|
||||
async getProjectTasks(userId: string, projectName: string): Promise<Task[]> {
|
||||
return this.data.tasks
|
||||
.filter(
|
||||
(t) =>
|
||||
t.userId === userId &&
|
||||
!t.completed &&
|
||||
t.project?.toLowerCase() === projectName.toLowerCase()
|
||||
)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
async completeTask(userId: string, taskIndex: number): Promise<Task | null> {
|
||||
const userTasks = this.data.tasks.filter((t) => t.userId === userId && !t.completed);
|
||||
if (taskIndex < 1 || taskIndex > userTasks.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const task = userTasks[taskIndex - 1];
|
||||
task.completed = true;
|
||||
task.completedAt = new Date().toISOString();
|
||||
await this.saveData();
|
||||
this.logger.log(`Completed task "${task.title}" for user ${userId}`);
|
||||
return task;
|
||||
}
|
||||
|
||||
async deleteTask(userId: string, taskIndex: number): Promise<Task | null> {
|
||||
const userTasks = this.data.tasks.filter((t) => t.userId === userId && !t.completed);
|
||||
if (taskIndex < 1 || taskIndex > userTasks.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const task = userTasks[taskIndex - 1];
|
||||
this.data.tasks = this.data.tasks.filter((t) => t.id !== task.id);
|
||||
await this.saveData();
|
||||
this.logger.log(`Deleted task "${task.title}" for user ${userId}`);
|
||||
return task;
|
||||
}
|
||||
|
||||
// Project operations
|
||||
|
||||
async getProjects(userId: string): Promise<Project[]> {
|
||||
// Get unique projects from tasks
|
||||
const projectNames = new Set<string>();
|
||||
this.data.tasks
|
||||
.filter((t) => t.userId === userId && t.project)
|
||||
.forEach((t) => projectNames.add(t.project!));
|
||||
|
||||
return Array.from(projectNames).map((name) => ({
|
||||
id: name.toLowerCase(),
|
||||
name,
|
||||
color: '#808080',
|
||||
userId,
|
||||
}));
|
||||
}
|
||||
|
||||
// Statistics
|
||||
|
||||
async getStats(
|
||||
userId: string
|
||||
): Promise<{ total: number; completed: number; pending: number; today: number }> {
|
||||
const userTasks = this.data.tasks.filter((t) => t.userId === userId);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
return {
|
||||
total: userTasks.length,
|
||||
completed: userTasks.filter((t) => t.completed).length,
|
||||
pending: userTasks.filter((t) => !t.completed).length,
|
||||
today: userTasks.filter((t) => !t.completed && t.dueDate?.startsWith(today)).length,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse task input for priority and date
|
||||
parseTaskInput(input: string): {
|
||||
title: string;
|
||||
priority: number;
|
||||
dueDate: string | null;
|
||||
project: string | null;
|
||||
} {
|
||||
let title = input;
|
||||
let priority = 4;
|
||||
let dueDate: string | null = null;
|
||||
let project: string | null = null;
|
||||
|
||||
// Parse priority (!p1, !p2, !p3, !p4)
|
||||
const priorityMatch = title.match(/!p([1-4])/i);
|
||||
if (priorityMatch) {
|
||||
priority = parseInt(priorityMatch[1]);
|
||||
title = title.replace(/!p[1-4]/i, '').trim();
|
||||
}
|
||||
|
||||
// Parse date (@heute, @morgen, @übermorgen)
|
||||
const today = new Date();
|
||||
if (/@heute/i.test(title)) {
|
||||
dueDate = today.toISOString().split('T')[0];
|
||||
title = title.replace(/@heute/i, '').trim();
|
||||
} else if (/@morgen/i.test(title)) {
|
||||
today.setDate(today.getDate() + 1);
|
||||
dueDate = today.toISOString().split('T')[0];
|
||||
title = title.replace(/@morgen/i, '').trim();
|
||||
} else if (/@übermorgen/i.test(title)) {
|
||||
today.setDate(today.getDate() + 2);
|
||||
dueDate = today.toISOString().split('T')[0];
|
||||
title = title.replace(/@übermorgen/i, '').trim();
|
||||
}
|
||||
|
||||
// Parse project (#projektname)
|
||||
const projectMatch = title.match(/#(\S+)/);
|
||||
if (projectMatch) {
|
||||
project = projectMatch[1];
|
||||
title = title.replace(/#\S+/, '').trim();
|
||||
}
|
||||
|
||||
return { title, priority, dueDate, project };
|
||||
}
|
||||
}
|
||||
4
services/matrix-todo-bot/tsconfig.build.json
Normal file
4
services/matrix-todo-bot/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
22
services/matrix-todo-bot/tsconfig.json
Normal file
22
services/matrix-todo-bot/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue