import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent, UserListMapper, KeywordCommandDetector, COMMON_KEYWORDS, } from '@manacore/matrix-bot-common'; import { StorageService, StorageFile, Folder, ShareLink, TrashItem, } from '../storage/storage.service'; import { SessionService, TranscriptionService, CreditService, LOGIN_MESSAGES, } from '@manacore/bot-services'; import { HELP_MESSAGE } from '../config/configuration'; @Injectable() export class MatrixService extends BaseMatrixService { // Store last shown items per user for reference by number private filesMapper = new UserListMapper(); private foldersMapper = new UserListMapper(); private sharesMapper = new UserListMapper(); private trashMapper = new UserListMapper(); private currentFolder: Map = new Map(); private readonly keywordDetector = new KeywordCommandDetector([ ...COMMON_KEYWORDS, { keywords: ['dateien', 'files', 'meine dateien', 'liste'], command: 'dateien' }, { keywords: ['ordner', 'folders', 'verzeichnisse', 'dirs'], command: 'ordner' }, { keywords: ['teilen', 'share', 'freigeben', 'link erstellen'], command: 'teilen' }, { keywords: ['suche', 'search', 'finde', 'durchsuchen'], command: 'suche' }, { keywords: ['favoriten', 'favorites', 'favs', 'gemerkte'], command: 'favoriten' }, { keywords: ['papierkorb', 'trash', 'geloeschte', 'muell'], command: 'papierkorb' }, { keywords: ['links', 'shares', 'freigaben', 'geteilte'], command: 'links' }, ]); constructor( configService: ConfigService, private storageService: StorageService, private sessionService: SessionService, private readonly transcriptionService: TranscriptionService, private creditService: CreditService ) { super(configService); } protected getConfig(): MatrixBotConfig { return { homeserverUrl: this.configService.get('matrix.homeserverUrl') || 'http://localhost:8008', accessToken: this.configService.get('matrix.accessToken') || '', storagePath: this.configService.get('matrix.storagePath') || './data/bot-storage.json', allowedRooms: this.configService.get('matrix.allowedRooms') || [], }; } protected async handleTextMessage( roomId: string, event: MatrixRoomEvent, body: string ): Promise { // Check for keyword commands first const keywordCommand = this.keywordDetector.detect(body); if (keywordCommand) { body = `!${keywordCommand}`; } if (!body.startsWith('!')) return; const sender = event.sender; const parts = body.slice(1).split(/\s+/); const command = parts[0].toLowerCase(); const args = parts.slice(1); const argString = args.join(' '); try { switch (command) { case 'help': case 'hilfe': await this.sendMessage(roomId, HELP_MESSAGE); break; case 'status': await this.handleStatus(roomId, sender); break; // File commands case 'dateien': case 'files': case 'ls': await this.handleListFiles(roomId, sender, args[0]); break; case 'datei': case 'file': case 'info': await this.handleFileDetails(roomId, sender, args[0]); break; case 'download': case 'dl': await this.handleDownload(roomId, sender, args[0]); break; case 'loeschen': case 'delete': case 'rm': await this.handleDeleteFile(roomId, sender, args[0]); break; case 'umbenennen': case 'rename': case 'mv': await this.handleRenameFile(roomId, sender, args[0], args.slice(1).join(' ')); break; case 'verschieben': case 'move': await this.handleMoveFile(roomId, sender, args[0], args[1]); break; // Folder commands case 'ordner': case 'folders': case 'dir': await this.handleListFolders(roomId, sender, args[0]); break; case 'neuordner': case 'mkdir': case 'newfolder': await this.handleCreateFolder(roomId, sender, args); break; case 'ordnerloeschen': case 'rmdir': await this.handleDeleteFolder(roomId, sender, args[0]); break; // Share commands case 'teilen': case 'share': await this.handleShareFile(roomId, sender, argString); break; case 'links': case 'shares': await this.handleListShares(roomId, sender); break; case 'linkloeschen': case 'unshare': await this.handleDeleteShare(roomId, sender, args[0]); break; // Organization case 'suche': case 'search': case 'find': await this.handleSearch(roomId, sender, argString); break; case 'favoriten': case 'favorites': case 'favs': await this.handleFavorites(roomId, sender); break; case 'fav': case 'favorit': await this.handleToggleFavorite(roomId, sender, args[0]); break; // Trash case 'papierkorb': case 'trash': await this.handleTrash(roomId, sender); break; case 'wiederherstellen': case 'restore': await this.handleRestore(roomId, sender, args[0]); break; case 'leeren': case 'emptytrash': await this.handleEmptyTrash(roomId, sender); break; default: await this.sendMessage( roomId, `

Unbekannter Befehl: ${command}. Nutze !help fuer Hilfe.

` ); } } catch (error) { this.logger.error(`Error handling command ${command}:`, error); await this.sendMessage(roomId, `

Fehler: ${(error as Error).message}

`); } } protected override async handleAudioMessage( roomId: string, event: MatrixRoomEvent, _sender: string ): Promise { try { const mxcUrl = event.content.url; if (!mxcUrl) return; const audioBuffer = await this.downloadMedia(mxcUrl); const text = await this.transcriptionService.transcribe(audioBuffer); if (!text) { await this.sendReply(roomId, event, '

Sprachnachricht konnte nicht erkannt werden.

'); return; } await this.sendMessage(roomId, `

"${text}"

`); await this.handleTextMessage(roomId, event, text); } catch (error) { this.logger.error(`Audio transcription error: ${error}`); await this.sendReply(roomId, event, '

Fehler bei der Spracherkennung.

'); } } private async requireAuth(sender: string): Promise { const token = await this.sessionService.getToken(sender); if (!token) { throw new Error(LOGIN_MESSAGES.storage); } return token; } private async handleStatus(roomId: string, sender: string) { const backendOk = await this.storageService.checkHealth(); const loggedIn = await this.sessionService.isLoggedIn(sender); const sessions = await this.sessionService.getSessionCount(); const session = await this.sessionService.getSession(sender); const token = await this.sessionService.getToken(sender); let statusHtml = '

Storage Bot Status

    '; statusHtml += `
  • Backend: ${backendOk ? 'Online' : 'Offline'}
  • `; statusHtml += `
  • Angemeldet: ${loggedIn ? 'Ja' : 'Nein'}
  • `; if (loggedIn && session && token) { const balance = await this.creditService.getBalance(token); statusHtml += `
  • 👤 Angemeldet als: ${session.email}
  • `; statusHtml += `
  • âš¡ Credits: ${balance.balance.toFixed(2)}
  • `; } statusHtml += `
  • Aktive Sessions: ${sessions}
  • `; statusHtml += '
'; await this.sendMessage(roomId, statusHtml); } // File handlers private async handleListFiles(roomId: string, sender: string, folderNumStr?: string) { const token = await this.requireAuth(sender); let parentFolderId: string | undefined; if (folderNumStr) { const folder = this.getFolderByNumber(sender, folderNumStr); if (!folder) { await this.sendMessage(roomId, '

Ungueltige Ordner-Nummer.

'); return; } parentFolderId = folder.id; this.currentFolder.set(sender, folder.id); } else { this.currentFolder.set(sender, null); } const result = await this.storageService.getFiles(token, parentFolderId); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } const files = result.data || []; this.filesMapper.setList(sender, files); if (files.length === 0) { await this.sendMessage(roomId, '

Keine Dateien vorhanden.

'); return; } let html = '

Dateien

    '; for (const file of files) { const size = this.formatSize(file.size); const fav = file.isFavorite ? ' ⭐' : ''; html += `
  1. ${file.name} (${size})${fav}
  2. `; } html += '
'; html += '

Nutze !datei [nr] fuer Details oder !download [nr]

'; await this.sendMessage(roomId, html); } private async handleFileDetails(roomId: string, sender: string, numberStr: string) { const token = await this.requireAuth(sender); const file = this.getFileByNumber(sender, numberStr); if (!file) { await this.sendMessage( roomId, '

Ungueltige Nummer. Nutze zuerst !dateien

' ); return; } const result = await this.storageService.getFile(token, file.id); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } const f = result.data!; const fav = f.isFavorite ? ' ⭐' : ''; let html = `

${f.name}${fav}

`; html += '
    '; html += `
  • Typ: ${f.mimeType}
  • `; html += `
  • Groesse: ${this.formatSize(f.size)}
  • `; html += `
  • Erstellt: ${new Date(f.createdAt).toLocaleDateString('de-DE')}
  • `; html += `
  • Aktualisiert: ${new Date(f.updatedAt).toLocaleDateString('de-DE')}
  • `; html += '
'; html += `

Nutze !download ${numberStr} fuer Download-Link

`; await this.sendMessage(roomId, html); } private async handleDownload(roomId: string, sender: string, numberStr: string) { const token = await this.requireAuth(sender); const file = this.getFileByNumber(sender, numberStr); if (!file) { await this.sendMessage( roomId, '

Ungueltige Nummer. Nutze zuerst !dateien

' ); return; } const result = await this.storageService.getDownloadUrl(token, file.id); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } await this.sendMessage( roomId, `

${file.name}

Download: ${result.data!.url}

` ); } private async handleDeleteFile(roomId: string, sender: string, numberStr: string) { const token = await this.requireAuth(sender); const file = this.getFileByNumber(sender, numberStr); if (!file) { await this.sendMessage( roomId, '

Ungueltige Nummer. Nutze zuerst !dateien

' ); return; } const result = await this.storageService.deleteFile(token, file.id); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } this.filesMapper.clearList(sender); await this.sendMessage( roomId, `

${file.name} in Papierkorb verschoben.

` ); } private async handleRenameFile( roomId: string, sender: string, numberStr: string, newName: string ) { if (!newName) { await this.sendMessage(roomId, '

Verwendung: !umbenennen [nr] neuer name

'); return; } const token = await this.requireAuth(sender); const file = this.getFileByNumber(sender, numberStr); if (!file) { await this.sendMessage( roomId, '

Ungueltige Nummer. Nutze zuerst !dateien

' ); return; } const result = await this.storageService.renameFile(token, file.id, newName); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } await this.sendMessage( roomId, `

${file.name} umbenannt zu ${newName}

` ); } private async handleMoveFile( roomId: string, sender: string, fileNumStr: string, folderNumStr: string ) { const token = await this.requireAuth(sender); const file = this.getFileByNumber(sender, fileNumStr); if (!file) { await this.sendMessage(roomId, '

Ungueltige Datei-Nummer.

'); return; } let parentFolderId: string | null = null; let folderName = 'Root'; if (folderNumStr && folderNumStr !== '0' && folderNumStr.toLowerCase() !== 'root') { const folder = this.getFolderByNumber(sender, folderNumStr); if (!folder) { await this.sendMessage( roomId, '

Ungueltige Ordner-Nummer. Nutze 0 oder root fuer Root.

' ); return; } parentFolderId = folder.id; folderName = folder.name; } const result = await this.storageService.moveFile(token, file.id, parentFolderId); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } await this.sendMessage( roomId, `

${file.name} nach ${folderName} verschoben.

` ); } // Folder handlers private async handleListFolders(roomId: string, sender: string, folderNumStr?: string) { const token = await this.requireAuth(sender); let parentFolderId: string | undefined; if (folderNumStr) { const folder = this.getFolderByNumber(sender, folderNumStr); if (!folder) { await this.sendMessage(roomId, '

Ungueltige Ordner-Nummer.

'); return; } parentFolderId = folder.id; } const result = await this.storageService.getFolders(token, parentFolderId); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } const folders = result.data || []; this.foldersMapper.setList(sender, folders); if (folders.length === 0) { await this.sendMessage(roomId, '

Keine Ordner vorhanden.

'); return; } let html = '

Ordner

    '; for (const folder of folders) { const fav = folder.isFavorite ? ' ⭐' : ''; const color = folder.color ? ` [${folder.color}]` : ''; html += `
  1. ${folder.name}${color}${fav}
  2. `; } html += '
'; html += '

Nutze !dateien [nr] um Dateien im Ordner zu sehen

'; await this.sendMessage(roomId, html); } private async handleCreateFolder(roomId: string, sender: string, args: string[]) { if (args.length === 0) { await this.sendMessage( roomId, '

Verwendung: !neuordner Name [in-ordner-nr]

' ); return; } const token = await this.requireAuth(sender); // Check if last arg is a number (parent folder) let parentFolderId: string | undefined; let name = args.join(' '); const lastArg = args[args.length - 1]; if (/^\d+$/.test(lastArg) && args.length > 1) { const folder = this.getFolderByNumber(sender, lastArg); if (folder) { parentFolderId = folder.id; name = args.slice(0, -1).join(' '); } } const result = await this.storageService.createFolder(token, name, parentFolderId); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } this.foldersMapper.clearList(sender); await this.sendMessage(roomId, `

Ordner ${result.data!.name} erstellt.

`); } private async handleDeleteFolder(roomId: string, sender: string, numberStr: string) { const token = await this.requireAuth(sender); const folder = this.getFolderByNumber(sender, numberStr); if (!folder) { await this.sendMessage(roomId, '

Ungueltige Nummer. Nutze zuerst !ordner

'); return; } const result = await this.storageService.deleteFolder(token, folder.id); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } this.foldersMapper.clearList(sender); await this.sendMessage( roomId, `

Ordner ${folder.name} in Papierkorb verschoben.

` ); } // Share handlers private async handleShareFile(roomId: string, sender: string, argString: string) { const token = await this.requireAuth(sender); // Parse arguments const args = argString.split(/\s+/); const numberStr = args[0]; const file = this.getFileByNumber(sender, numberStr); if (!file) { await this.sendMessage( roomId, '

Ungueltige Nummer. Nutze zuerst !dateien

' ); return; } const options: { expiresInDays?: number; password?: string; maxDownloads?: number } = {}; // Parse --tage N const daysMatch = argString.match(/--tage\s+(\d+)/i); if (daysMatch) { options.expiresInDays = parseInt(daysMatch[1], 10); } // Parse --passwort XXX const passMatch = argString.match(/--passwort\s+(\S+)/i); if (passMatch) { options.password = passMatch[1]; } // Parse --downloads N const dlMatch = argString.match(/--downloads\s+(\d+)/i); if (dlMatch) { options.maxDownloads = parseInt(dlMatch[1], 10); } const result = await this.storageService.createShare(token, file.id, options); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } const share = result.data!; const shareUrl = `${this.configService.get('storage.backendUrl')}/public/shares/${share.shareToken}`; let html = `

${file.name} wird geteilt:

`; html += `

${shareUrl}

`; if (options.expiresInDays) html += `

Gueltig: ${options.expiresInDays} Tage

`; if (options.password) html += `

Passwort geschuetzt

`; if (options.maxDownloads) html += `

Max Downloads: ${options.maxDownloads}

`; await this.sendMessage(roomId, html); } private async handleListShares(roomId: string, sender: string) { const token = await this.requireAuth(sender); const result = await this.storageService.getShares(token); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } const shares = result.data || []; this.sharesMapper.setList(sender, shares); if (shares.length === 0) { await this.sendMessage(roomId, '

Keine Share-Links vorhanden.

'); return; } let html = '

Share-Links

    '; for (const share of shares) { const expires = share.expiresAt ? ` (bis ${new Date(share.expiresAt).toLocaleDateString('de-DE')})` : ''; const downloads = share.maxDownloads ? ` [${share.downloadCount}/${share.maxDownloads}]` : ` [${share.downloadCount} DL]`; html += `
  1. ${share.shareType}${expires}${downloads}
  2. `; } html += '
'; html += '

Nutze !linkloeschen [nr] zum Loeschen

'; await this.sendMessage(roomId, html); } private async handleDeleteShare(roomId: string, sender: string, numberStr: string) { const token = await this.requireAuth(sender); if (!this.sharesMapper.hasList(sender)) { await this.sendMessage(roomId, '

Nutze zuerst !links

'); return; } const num = parseInt(numberStr, 10); const share = isNaN(num) ? null : this.sharesMapper.getByNumber(sender, num); if (!share) { await this.sendMessage(roomId, '

Ungueltige Nummer.

'); return; } const result = await this.storageService.deleteShare(token, share.id); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } this.sharesMapper.clearList(sender); await this.sendMessage(roomId, '

Share-Link geloescht.

'); } // Search & Favorites private async handleSearch(roomId: string, sender: string, query: string) { if (!query) { await this.sendMessage(roomId, '

Verwendung: !suche Begriff

'); return; } const token = await this.requireAuth(sender); const result = await this.storageService.search(token, query); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } const { files, folders } = result.data!; this.filesMapper.setList(sender, files); this.foldersMapper.setList(sender, folders); if (files.length === 0 && folders.length === 0) { await this.sendMessage(roomId, `

Keine Ergebnisse fuer "${query}"

`); return; } let html = `

Suchergebnisse: "${query}"

`; if (folders.length > 0) { html += '

Ordner:

    '; for (const folder of folders) { html += `
  1. ${folder.name}
  2. `; } html += '
'; } if (files.length > 0) { html += '

Dateien:

    '; for (const file of files) { html += `
  1. ${file.name} (${this.formatSize(file.size)})
  2. `; } html += '
'; } await this.sendMessage(roomId, html); } private async handleFavorites(roomId: string, sender: string) { const token = await this.requireAuth(sender); const result = await this.storageService.getFavorites(token); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } const { files, folders } = result.data!; this.filesMapper.setList(sender, files); this.foldersMapper.setList(sender, folders); if (files.length === 0 && folders.length === 0) { await this.sendMessage(roomId, '

Keine Favoriten vorhanden.

'); return; } let html = '

Favoriten ⭐

'; if (folders.length > 0) { html += '

Ordner:

    '; for (const folder of folders) { html += `
  1. ${folder.name}
  2. `; } html += '
'; } if (files.length > 0) { html += '

Dateien:

    '; for (const file of files) { html += `
  1. ${file.name}
  2. `; } html += '
'; } await this.sendMessage(roomId, html); } private async handleToggleFavorite(roomId: string, sender: string, numberStr: string) { const token = await this.requireAuth(sender); // Try file first const file = this.getFileByNumber(sender, numberStr); if (file) { const result = await this.storageService.toggleFileFavorite(token, file.id); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } const status = result.data!.isFavorite ? 'hinzugefuegt' : 'entfernt'; await this.sendMessage(roomId, `

${file.name}: Favorit ${status}

`); return; } // Try folder const folder = this.getFolderByNumber(sender, numberStr); if (folder) { const result = await this.storageService.toggleFolderFavorite(token, folder.id); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } const status = result.data!.isFavorite ? 'hinzugefuegt' : 'entfernt'; await this.sendMessage(roomId, `

${folder.name}: Favorit ${status}

`); return; } await this.sendMessage(roomId, '

Ungueltige Nummer.

'); } // Trash handlers private async handleTrash(roomId: string, sender: string) { const token = await this.requireAuth(sender); const result = await this.storageService.getTrash(token); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } const items = result.data || []; this.trashMapper.setList(sender, items); if (items.length === 0) { await this.sendMessage(roomId, '

Papierkorb ist leer.

'); return; } let html = '

Papierkorb

    '; for (const item of items) { const type = item.type === 'folder' ? '📁' : '📄'; const deleted = new Date(item.deletedAt).toLocaleDateString('de-DE'); html += `
  1. ${type} ${item.name} (geloescht: ${deleted})
  2. `; } html += '
'; html += '

Nutze !wiederherstellen [nr] oder !leeren

'; await this.sendMessage(roomId, html); } private async handleRestore(roomId: string, sender: string, numberStr: string) { const token = await this.requireAuth(sender); if (!this.trashMapper.hasList(sender)) { await this.sendMessage(roomId, '

Nutze zuerst !papierkorb

'); return; } const num = parseInt(numberStr, 10); const item = isNaN(num) ? null : this.trashMapper.getByNumber(sender, num); if (!item) { await this.sendMessage(roomId, '

Ungueltige Nummer.

'); return; } const result = await this.storageService.restoreFromTrash(token, item.id, item.type); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } this.trashMapper.clearList(sender); await this.sendMessage(roomId, `

${item.name} wiederhergestellt.

`); } private async handleEmptyTrash(roomId: string, sender: string) { const token = await this.requireAuth(sender); const result = await this.storageService.emptyTrash(token); if (result.error) { await this.sendMessage(roomId, `

Fehler: ${result.error}

`); return; } this.trashMapper.clearList(sender); await this.sendMessage(roomId, '

Papierkorb geleert.

'); } // Helper methods private getFileByNumber(sender: string, numberStr: string): StorageFile | null { const num = parseInt(numberStr, 10); if (isNaN(num)) return null; return this.filesMapper.getByNumber(sender, num); } private getFolderByNumber(sender: string, numberStr: string): Folder | null { const num = parseInt(numberStr, 10); if (isNaN(num)) return null; return this.foldersMapper.getByNumber(sender, num); } private formatSize(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } }