From a6fc1cb66ed11146beca9a1fecddd468b093d56b Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sat, 14 Feb 2026 12:42:41 +0100 Subject: [PATCH] feat(onboarding): add Matrix onboarding bot for profile setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add matrix-onboarding-bot service that guides users through profile setup - Extend mana-core-auth GlobalSettings with displayName, interests, onboardingCompleted fields - Implement state machine for onboarding flow (NAME → INTERESTS → LANGUAGE → SUMMARY) - Support commands: !start, !profile, !edit, !skip, !help - Add German and English localization - Integrate with mana-core-auth Settings API for profile persistence Co-Authored-By: Claude Opus 4.5 --- .../mana-core-auth/src/settings/dto/index.ts | 18 + .../src/settings/settings.service.ts | 4 + services/matrix-onboarding-bot/CLAUDE.md | 193 ++++++++++ services/matrix-onboarding-bot/Dockerfile | 71 ++++ services/matrix-onboarding-bot/package.json | 43 +++ .../matrix-onboarding-bot/src/app.module.ts | 18 + .../src/bot/bot.module.ts | 15 + .../src/bot/matrix.service.ts | 364 ++++++++++++++++++ .../src/config/configuration.ts | 87 +++++ services/matrix-onboarding-bot/src/main.ts | 15 + .../src/onboarding/onboarding.module.ts | 8 + .../src/onboarding/onboarding.service.ts | 232 +++++++++++ .../src/onboarding/state-machine.ts | 362 +++++++++++++++++ .../matrix-onboarding-bot/tsconfig.build.json | 4 + services/matrix-onboarding-bot/tsconfig.json | 22 ++ 15 files changed, 1456 insertions(+) create mode 100644 services/matrix-onboarding-bot/CLAUDE.md create mode 100644 services/matrix-onboarding-bot/Dockerfile create mode 100644 services/matrix-onboarding-bot/package.json create mode 100644 services/matrix-onboarding-bot/src/app.module.ts create mode 100644 services/matrix-onboarding-bot/src/bot/bot.module.ts create mode 100644 services/matrix-onboarding-bot/src/bot/matrix.service.ts create mode 100644 services/matrix-onboarding-bot/src/config/configuration.ts create mode 100644 services/matrix-onboarding-bot/src/main.ts create mode 100644 services/matrix-onboarding-bot/src/onboarding/onboarding.module.ts create mode 100644 services/matrix-onboarding-bot/src/onboarding/onboarding.service.ts create mode 100644 services/matrix-onboarding-bot/src/onboarding/state-machine.ts create mode 100644 services/matrix-onboarding-bot/tsconfig.build.json create mode 100644 services/matrix-onboarding-bot/tsconfig.json diff --git a/services/mana-core-auth/src/settings/dto/index.ts b/services/mana-core-auth/src/settings/dto/index.ts index ac12fd1b2..986140021 100644 --- a/services/mana-core-auth/src/settings/dto/index.ts +++ b/services/mana-core-auth/src/settings/dto/index.ts @@ -60,6 +60,20 @@ export class UpdateGlobalSettingsDto { @IsArray() @IsString({ each: true }) recentEmojis?: string[]; + + // Profile fields (from onboarding) + @IsOptional() + @IsString() + displayName?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + interests?: string[]; + + @IsOptional() + @IsBoolean() + onboardingCompleted?: boolean; } // App override update @@ -121,6 +135,10 @@ export interface GlobalSettings { theme: ThemeSettings; locale: string; recentEmojis?: string[]; + // Profile fields (from onboarding) + displayName?: string; + interests?: string[]; + onboardingCompleted?: boolean; } export interface AppOverride { diff --git a/services/mana-core-auth/src/settings/settings.service.ts b/services/mana-core-auth/src/settings/settings.service.ts index 74f1c1537..e65efc9f0 100644 --- a/services/mana-core-auth/src/settings/settings.service.ts +++ b/services/mana-core-auth/src/settings/settings.service.ts @@ -92,6 +92,10 @@ export class SettingsService { theme: { ...current.globalSettings.theme, ...dto.theme }, locale: dto.locale ?? current.globalSettings.locale, recentEmojis: dto.recentEmojis ?? current.globalSettings.recentEmojis, + // Profile fields + displayName: dto.displayName ?? current.globalSettings.displayName, + interests: dto.interests ?? current.globalSettings.interests, + onboardingCompleted: dto.onboardingCompleted ?? current.globalSettings.onboardingCompleted, }; // Update in database diff --git a/services/matrix-onboarding-bot/CLAUDE.md b/services/matrix-onboarding-bot/CLAUDE.md new file mode 100644 index 000000000..deb257118 --- /dev/null +++ b/services/matrix-onboarding-bot/CLAUDE.md @@ -0,0 +1,193 @@ +# Matrix Onboarding Bot - Claude Code Guidelines + +## Overview + +Matrix Onboarding Bot guides new users through a profile setup process. It collects display name, interests, and language preference, storing them in mana-core-auth's globalSettings. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Matrix**: matrix-bot-sdk via @manacore/matrix-bot-common +- **Auth**: mana-core-auth (Settings API) +- **Sessions**: Redis via @manacore/bot-services + +## 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-onboarding-bot/ +├── src/ +│ ├── main.ts # Application entry point (port 4020) +│ ├── app.module.ts # Root module +│ ├── config/ +│ │ └── configuration.ts # Configuration & messages (de/en) +│ ├── bot/ +│ │ ├── bot.module.ts +│ │ └── matrix.service.ts # Matrix client & command handlers +│ └── onboarding/ +│ ├── onboarding.module.ts +│ ├── onboarding.service.ts # API client for mana-core-auth +│ └── state-machine.ts # Onboarding flow state machine +├── Dockerfile +└── package.json +``` + +## Bot Commands + +| Command | Description | +|---------|-------------| +| `!start` | Start onboarding (or restart if completed) | +| `!profile` | Show current profile | +| `!edit name Max` | Change display name | +| `!edit interests KI, Musik` | Change interests | +| `!edit language de` | Change language (de/en) | +| `!skip` | Skip current question (if allowed) | +| `!cancel` | Cancel onboarding | +| `!help` | Show help text | + +## Onboarding Flow + +``` +IDLE → NAME → INTERESTS → LANGUAGE → SUMMARY → COMPLETED + ↓ ↓ + SKIP SKIP +``` + +1. **NAME** (required): Ask for display name +2. **INTERESTS** (skippable): Ask for interests (comma-separated) +3. **LANGUAGE** (skippable): Ask for language preference (de/en) +4. **SUMMARY**: Show profile and ask for confirmation +5. **COMPLETED**: Save to mana-core-auth and finish + +## Data Storage + +Profile data is stored in mana-core-auth's `user_settings.globalSettings`: + +```typescript +interface GlobalSettings { + // ... existing fields + displayName?: string; // From onboarding + interests?: string[]; // From onboarding + onboardingCompleted?: boolean; +} +``` + +## Environment Variables + +```env +# Server +PORT=4020 + +# Matrix +MATRIX_HOMESERVER_URL=http://localhost:8008 +MATRIX_ACCESS_TOKEN=syt_xxx +MATRIX_ALLOWED_ROOMS=#onboarding:matrix.mana.how +MATRIX_STORAGE_PATH=./data/bot-storage.json + +# mana-core-auth +MANA_CORE_AUTH_URL=http://localhost:3001 +MANA_CORE_SERVICE_KEY=your-service-key + +# Redis (for session storage) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=redis123 +``` + +## API Endpoints Used + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/settings` | GET | Get user settings | +| `/api/v1/settings/global` | PATCH | Update global settings | + +## Docker + +```bash +# Build +docker build -f services/matrix-onboarding-bot/Dockerfile -t matrix-onboarding-bot . + +# Run +docker run -p 4020:4020 \ + -e MATRIX_HOMESERVER_URL=http://synapse:8008 \ + -e MATRIX_ACCESS_TOKEN=syt_xxx \ + -e MANA_CORE_AUTH_URL=http://mana-auth:3001 \ + -e REDIS_HOST=redis \ + -v matrix-bots-data:/app/data \ + matrix-onboarding-bot +``` + +## Health Check + +```bash +curl http://localhost:4020/health +``` + +## Authentication + +The bot requires users to be logged in via Matrix-SSO-Link (shared Redis session). + +1. User logs in via Matrix SSO link in another bot or web app +2. Session stored in Redis with Matrix user ID as key +3. Onboarding bot retrieves token from Redis +4. Token used to call mana-core-auth Settings API + +## Example Dialog + +``` +Bot: Willkommen beim Onboarding! + + Ich helfe dir, dein Profil einzurichten. + Wie mochtest du genannt werden? + +User: Max + +Bot: Hallo Max! Was sind deine Interessen? + (z.B. Programmierung, Musik - durch Komma getrennt) + Sag `!skip` zum Uberspringen. + +User: KI, Gaming, Musik + +Bot: Welche Sprache bevorzugst du? + Antworte mit `de` fur Deutsch oder `en` fur Englisch. + +User: de + +Bot: **Dein Profil:** + - Name: Max + - Interessen: KI, Gaming, Musik + - Sprache: Deutsch + + Ist das korrekt? (ja/nein) + +User: ja + +Bot: Perfekt! Dein Profil ist eingerichtet. + Du kannst es jederzeit mit `!profile` anzeigen oder mit `!edit` andern. +``` + +## Localization + +The bot supports German (de) and English (en). Messages are defined in `src/config/configuration.ts` under the `MESSAGES` object. + +## State Machine + +The `OnboardingStateMachine` class in `src/onboarding/state-machine.ts` is a pure function that: +- Takes current state + action +- Returns new state + message key +- Has no side effects + +This makes it easy to test and reason about the flow. diff --git a/services/matrix-onboarding-bot/Dockerfile b/services/matrix-onboarding-bot/Dockerfile new file mode 100644 index 000000000..6fb196ecb --- /dev/null +++ b/services/matrix-onboarding-bot/Dockerfile @@ -0,0 +1,71 @@ +# Build stage +FROM node:20-slim AS builder + +WORKDIR /app + +# Enable pnpm via corepack +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +# Copy workspace configuration +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ + +# Copy shared packages that this bot depends on +COPY packages/bot-services ./packages/bot-services +COPY packages/matrix-bot-common ./packages/matrix-bot-common + +# Copy this bot +COPY services/matrix-onboarding-bot ./services/matrix-onboarding-bot + +# Install all dependencies +RUN pnpm install --frozen-lockfile --ignore-scripts + +# Build shared packages first (in dependency order) +RUN pnpm --filter @manacore/bot-services build +RUN pnpm --filter @manacore/matrix-bot-common build + +# Build the bot +RUN pnpm --filter @manacore/matrix-onboarding-bot build + +# Production stage +FROM node:20-slim AS runner + +WORKDIR /app + +# Install wget for health checks and enable pnpm +RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* \ + && corepack enable && corepack prepare pnpm@9.15.0 --activate + +# Copy workspace configuration +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ + +# Copy built shared packages +COPY --from=builder /app/packages/bot-services/dist ./packages/bot-services/dist +COPY --from=builder /app/packages/bot-services/package.json ./packages/bot-services/ +COPY --from=builder /app/packages/matrix-bot-common/dist ./packages/matrix-bot-common/dist +COPY --from=builder /app/packages/matrix-bot-common/package.json ./packages/matrix-bot-common/ + +# Copy built bot +COPY --from=builder /app/services/matrix-onboarding-bot/dist ./services/matrix-onboarding-bot/dist +COPY --from=builder /app/services/matrix-onboarding-bot/package.json ./services/matrix-onboarding-bot/ + +# Install production dependencies only +RUN pnpm install --frozen-lockfile --prod --ignore-scripts + +# Create data directory +RUN mkdir -p /app/data + +# Create non-root user +RUN groupadd --system --gid 1001 nodejs && \ + useradd --system --uid 1001 -g nodejs nestjs && \ + chown -R nestjs:nodejs /app + +USER nestjs + +WORKDIR /app/services/matrix-onboarding-bot + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:4020/health || exit 1 + +EXPOSE 4020 + +CMD ["node", "dist/main.js"] diff --git a/services/matrix-onboarding-bot/package.json b/services/matrix-onboarding-bot/package.json new file mode 100644 index 000000000..98f2d2b6e --- /dev/null +++ b/services/matrix-onboarding-bot/package.json @@ -0,0 +1,43 @@ +{ + "name": "@manacore/matrix-onboarding-bot", + "version": "1.0.0", + "description": "Matrix bot for user onboarding and profile setup", + "private": true, + "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", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@manacore/bot-services": "workspace:*", + "@manacore/matrix-bot-common": "workspace:*", + "@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/express": "^5.0.6", + "@types/node": "^22.10.7", + "typescript": "^5.7.3" + } +} diff --git a/services/matrix-onboarding-bot/src/app.module.ts b/services/matrix-onboarding-bot/src/app.module.ts new file mode 100644 index 000000000..edd13d285 --- /dev/null +++ b/services/matrix-onboarding-bot/src/app.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; +import { BotModule } from './bot/bot.module'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + BotModule, + ], + controllers: [HealthController], + providers: [createHealthProvider('matrix-onboarding-bot')], +}) +export class AppModule {} diff --git a/services/matrix-onboarding-bot/src/bot/bot.module.ts b/services/matrix-onboarding-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..d9b38f7f6 --- /dev/null +++ b/services/matrix-onboarding-bot/src/bot/bot.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { MatrixService } from './matrix.service'; +import { OnboardingModule } from '../onboarding/onboarding.module'; +import { SessionModule, I18nModule } from '@manacore/bot-services'; + +@Module({ + imports: [ + OnboardingModule, + SessionModule.forRoot({ storageMode: 'redis' }), + I18nModule.forRoot(), + ], + providers: [MatrixService], + exports: [MatrixService], +}) +export class BotModule {} diff --git a/services/matrix-onboarding-bot/src/bot/matrix.service.ts b/services/matrix-onboarding-bot/src/bot/matrix.service.ts new file mode 100644 index 000000000..c7aaf7a43 --- /dev/null +++ b/services/matrix-onboarding-bot/src/bot/matrix.service.ts @@ -0,0 +1,364 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + BaseMatrixService, + type MatrixBotConfig, + type MatrixRoomEvent, +} from '@manacore/matrix-bot-common'; +import { SessionService, I18nService, type Language } from '@manacore/bot-services'; +import { OnboardingService } from '../onboarding/onboarding.service'; +import { HELP_TEXT, MESSAGES } from '../config/configuration'; + +@Injectable() +export class MatrixService extends BaseMatrixService { + constructor( + configService: ConfigService, + private readonly sessionService: SessionService, + private readonly i18nService: I18nService, + private readonly onboardingService: OnboardingService + ) { + 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 getIntroductionMessage(): string | null { + return MESSAGES.de.welcome; + } + + protected async handleTextMessage( + roomId: string, + event: MatrixRoomEvent, + message: string, + sender: string + ): Promise { + const lang = await this.getLanguage(sender); + + // Handle commands first + if (message.startsWith('!')) { + const [command, ...args] = message.slice(1).split(' '); + await this.handleCommand(roomId, event, sender, command.toLowerCase(), args.join(' '), lang); + return; + } + + // Check if user is in onboarding flow + if (this.onboardingService.isInOnboarding(sender)) { + await this.handleOnboardingInput(roomId, event, sender, message, lang); + return; + } + + // Natural language hints + const lowerMessage = message.toLowerCase(); + if (lowerMessage.includes('hilfe') || lowerMessage.includes('help')) { + await this.sendReply(roomId, event, HELP_TEXT); + return; + } + + if (lowerMessage.includes('profil') || lowerMessage.includes('profile')) { + await this.handleProfileCommand(roomId, event, sender, lang); + return; + } + + // No action for other messages + } + + private async handleCommand( + roomId: string, + event: MatrixRoomEvent, + userId: string, + command: string, + args: string, + lang: Language + ): Promise { + const messages = MESSAGES[lang]; + + switch (command) { + case 'start': + await this.handleStartCommand(roomId, event, userId, lang); + break; + + case 'profile': + case 'profil': + await this.handleProfileCommand(roomId, event, userId, lang); + break; + + case 'edit': + case 'bearbeiten': + await this.handleEditCommand(roomId, event, userId, args, lang); + break; + + case 'skip': + case 'ueberspringen': + await this.handleSkipCommand(roomId, event, userId, lang); + break; + + case 'help': + case 'hilfe': + await this.sendReply(roomId, event, HELP_TEXT); + break; + + case 'cancel': + case 'abbrechen': + await this.handleCancelCommand(roomId, event, userId, lang); + break; + + default: + // Unknown command - ignore or show help + break; + } + } + + private async handleStartCommand( + roomId: string, + event: MatrixRoomEvent, + userId: string, + lang: Language + ): Promise { + const messages = MESSAGES[lang]; + + // Check if user is logged in + const token = await this.getToken(userId); + if (!token) { + await this.sendReply(roomId, event, messages.loginRequired); + return; + } + + // Check if already onboarded + const hasCompleted = await this.onboardingService.hasCompletedOnboarding(token); + if (hasCompleted) { + // Allow restart + this.onboardingService.resetSession(userId); + } + + // Start onboarding + const result = this.onboardingService.processAction(userId, { type: 'START' }, lang); + const message = this.getMessage(result.messageKey, lang, result.messageParams); + await this.sendReply(roomId, event, message); + } + + private async handleProfileCommand( + roomId: string, + event: MatrixRoomEvent, + userId: string, + lang: Language + ): Promise { + const messages = MESSAGES[lang]; + + const token = await this.getToken(userId); + if (!token) { + await this.sendReply(roomId, event, messages.loginRequired); + return; + } + + const profile = await this.onboardingService.getProfile(token); + if (!profile || !profile.onboardingCompleted) { + await this.sendReply(roomId, event, messages.noProfile); + return; + } + + const message = this.formatMessage(messages.profileDisplay, { + name: profile.displayName || '-', + interests: profile.interests?.length ? profile.interests.join(', ') : '-', + language: profile.locale === 'en' ? 'English' : 'Deutsch', + }); + await this.sendReply(roomId, event, message); + } + + private async handleEditCommand( + roomId: string, + event: MatrixRoomEvent, + userId: string, + args: string, + lang: Language + ): Promise { + const messages = MESSAGES[lang]; + + const token = await this.getToken(userId); + if (!token) { + await this.sendReply(roomId, event, messages.loginRequired); + return; + } + + const parts = args.split(' '); + if (parts.length < 2) { + await this.sendReply( + roomId, + event, + lang === 'de' + ? 'Verwendung: `!edit [name|interests|language] [Wert]`' + : 'Usage: `!edit [name|interests|language] [value]`' + ); + return; + } + + const field = parts[0].toLowerCase(); + const value = parts.slice(1).join(' '); + + let fieldKey: 'displayName' | 'interests' | 'locale' | null = null; + if (field === 'name' || field === 'namen') { + fieldKey = 'displayName'; + } else if (field === 'interests' || field === 'interessen') { + fieldKey = 'interests'; + } else if (field === 'language' || field === 'sprache' || field === 'lang') { + fieldKey = 'locale'; + } + + if (!fieldKey) { + await this.sendReply( + roomId, + event, + lang === 'de' + ? 'Unbekanntes Feld. Verfugbar: name, interests, language' + : 'Unknown field. Available: name, interests, language' + ); + return; + } + + const success = await this.onboardingService.updateProfileField(token, fieldKey, value); + if (success) { + await this.sendReply(roomId, event, messages.updated); + } else { + await this.sendReply( + roomId, + event, + lang === 'de' ? 'Fehler beim Aktualisieren.' : 'Error updating.' + ); + } + } + + private async handleSkipCommand( + roomId: string, + event: MatrixRoomEvent, + userId: string, + lang: Language + ): Promise { + const messages = MESSAGES[lang]; + + if (!this.onboardingService.isInOnboarding(userId)) { + return; + } + + if (!this.onboardingService.canSkip(userId)) { + await this.sendReply(roomId, event, messages.skipNotAllowed); + return; + } + + const result = this.onboardingService.processAction(userId, { type: 'SKIP' }, lang); + const message = this.getMessage(result.messageKey, lang, result.messageParams); + await this.sendReply(roomId, event, message); + + // If completed after skip, save the profile + if (result.session.state === 'COMPLETED') { + await this.saveOnboardingData(userId, result.session.data, roomId, event, lang); + } + } + + private async handleCancelCommand( + roomId: string, + event: MatrixRoomEvent, + userId: string, + lang: Language + ): Promise { + const messages = MESSAGES[lang]; + + if (this.onboardingService.isInOnboarding(userId)) { + this.onboardingService.resetSession(userId); + await this.sendReply(roomId, event, messages.cancelled); + } + } + + private async handleOnboardingInput( + roomId: string, + event: MatrixRoomEvent, + userId: string, + input: string, + lang: Language + ): Promise { + const result = this.onboardingService.processAction( + userId, + { type: 'INPUT', value: input }, + lang + ); + + const message = this.getMessage(result.messageKey, lang, result.messageParams); + await this.sendReply(roomId, event, message); + + // If completed, save the profile + if (result.session.state === 'COMPLETED') { + await this.saveOnboardingData(userId, result.session.data, roomId, event, lang); + } + } + + private async saveOnboardingData( + userId: string, + data: { displayName?: string; interests?: string[]; locale?: 'de' | 'en' }, + roomId: string, + event: MatrixRoomEvent, + lang: Language + ): Promise { + const token = await this.getToken(userId); + if (!token) { + this.logger.error(`No token for user ${userId}, cannot save profile`); + return; + } + + const success = await this.onboardingService.saveProfile(token, data); + if (!success) { + await this.sendReply( + roomId, + event, + lang === 'de' + ? 'Hinweis: Profil konnte nicht gespeichert werden. Versuche es spater erneut.' + : 'Note: Profile could not be saved. Try again later.' + ); + } + + // Also update the i18n language + if (data.locale) { + await this.i18nService.setLanguage(userId, data.locale as Language); + } + + // Clear the session + this.onboardingService.resetSession(userId); + } + + // ============================================================================ + // Helper Methods + // ============================================================================ + + private async getToken(userId: string): Promise { + return this.sessionService.getToken(userId); + } + + private async getLanguage(userId: string): Promise { + return this.i18nService.getLanguage(userId); + } + + private getMessage(key: string, lang: Language, params?: Record): string { + const messages = MESSAGES[lang]; + let message = (messages as Record)[key] || key; + + if (params) { + message = this.formatMessage(message, params); + } + + return message; + } + + private formatMessage(template: string, params: Record): string { + let result = template; + for (const [key, value] of Object.entries(params)) { + result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), value); + } + return result; + } +} diff --git a/services/matrix-onboarding-bot/src/config/configuration.ts b/services/matrix-onboarding-bot/src/config/configuration.ts new file mode 100644 index 000000000..577239694 --- /dev/null +++ b/services/matrix-onboarding-bot/src/config/configuration.ts @@ -0,0 +1,87 @@ +export default () => ({ + port: parseInt(process.env.PORT || '4020', 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', + }, + manaAuth: { + url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', + }, +}); + +export const HELP_TEXT = `**Onboarding Bot - Profil einrichten** + +**Befehle:** +- \`!start\` - Onboarding starten/neustarten +- \`!profile\` - Dein Profil anzeigen +- \`!edit name Max\` - Namen andern +- \`!edit interests KI, Musik\` - Interessen andern +- \`!edit language de\` - Sprache andern (de/en) +- \`!skip\` - Aktuelle Frage uberspringen +- \`!help\` - Diese Hilfe anzeigen + +**Onboarding-Flow:** +1. Anzeigename eingeben +2. Interessen angeben (optional) +3. Sprache wahlen (de/en) +4. Profil bestatigen`; + +export const WELCOME_TEXT = `**Willkommen beim Onboarding!** + +Ich helfe dir, dein Profil einzurichten. Das dauert nur einen Moment. + +Wie mochtest du genannt werden?`; + +export const MESSAGES = { + de: { + welcome: + '**Willkommen beim Onboarding!**\n\nIch helfe dir, dein Profil einzurichten. Das dauert nur einen Moment.\n\nWie mochtest du genannt werden?', + askName: 'Wie mochtest du genannt werden?', + askInterests: + 'Hallo **{name}**! Was sind deine Interessen?\n(z.B. Programmierung, Musik, Gaming - durch Komma getrennt)\n\nSag `!skip` zum Uberspringen.', + askLanguage: + 'Welche Sprache bevorzugst du?\n\nAntworte mit `de` fur Deutsch oder `en` fur Englisch.', + summary: + '**Dein Profil:**\n- Name: {name}\n- Interessen: {interests}\n- Sprache: {language}\n\nIst das korrekt? (ja/nein)', + completed: + 'Perfekt! Dein Profil ist eingerichtet. Du kannst es jederzeit mit `!profile` anzeigen oder mit `!edit` andern.', + cancelled: 'Onboarding abgebrochen. Starte jederzeit neu mit `!start`.', + profileDisplay: + '**Dein Profil:**\n- Name: {name}\n- Interessen: {interests}\n- Sprache: {language}', + noProfile: 'Du hast noch kein Profil eingerichtet. Starte mit `!start`.', + updated: 'Profil aktualisiert!', + invalidLanguage: 'Bitte wahle `de` oder `en`.', + skipNotAllowed: 'Diese Frage kann nicht ubersprungen werden.', + skipped: 'Ubersprungen.', + alreadyOnboarded: + 'Du hast das Onboarding bereits abgeschlossen. Nutze `!profile` zum Anzeigen oder `!edit` zum Andern.', + restartPrompt: 'Mochtest du das Onboarding neu starten? (ja/nein)', + loginRequired: 'Bitte melde dich zuerst an, um das Onboarding zu starten.', + }, + en: { + welcome: + "**Welcome to Onboarding!**\n\nI'll help you set up your profile. This will only take a moment.\n\nWhat would you like to be called?", + askName: 'What would you like to be called?', + askInterests: + 'Hello **{name}**! What are your interests?\n(e.g. Programming, Music, Gaming - separated by commas)\n\nSay `!skip` to skip.', + askLanguage: 'Which language do you prefer?\n\nReply with `de` for German or `en` for English.', + summary: + '**Your Profile:**\n- Name: {name}\n- Interests: {interests}\n- Language: {language}\n\nIs this correct? (yes/no)', + completed: + 'Perfect! Your profile is set up. You can view it anytime with `!profile` or change it with `!edit`.', + cancelled: 'Onboarding cancelled. Start again anytime with `!start`.', + profileDisplay: + '**Your Profile:**\n- Name: {name}\n- Interests: {interests}\n- Language: {language}', + noProfile: "You haven't set up a profile yet. Start with `!start`.", + updated: 'Profile updated!', + invalidLanguage: 'Please choose `de` or `en`.', + skipNotAllowed: 'This question cannot be skipped.', + skipped: 'Skipped.', + alreadyOnboarded: + 'You have already completed onboarding. Use `!profile` to view or `!edit` to change.', + restartPrompt: 'Would you like to restart onboarding? (yes/no)', + loginRequired: 'Please log in first to start onboarding.', + }, +}; diff --git a/services/matrix-onboarding-bot/src/main.ts b/services/matrix-onboarding-bot/src/main.ts new file mode 100644 index 000000000..3cbb06b78 --- /dev/null +++ b/services/matrix-onboarding-bot/src/main.ts @@ -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 || 4020; + + await app.listen(port); + + const logger = new Logger('Bootstrap'); + logger.log(`Matrix Onboarding Bot running on port ${port}`); +} + +bootstrap(); diff --git a/services/matrix-onboarding-bot/src/onboarding/onboarding.module.ts b/services/matrix-onboarding-bot/src/onboarding/onboarding.module.ts new file mode 100644 index 000000000..14c92b502 --- /dev/null +++ b/services/matrix-onboarding-bot/src/onboarding/onboarding.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { OnboardingService } from './onboarding.service'; + +@Module({ + providers: [OnboardingService], + exports: [OnboardingService], +}) +export class OnboardingModule {} diff --git a/services/matrix-onboarding-bot/src/onboarding/onboarding.service.ts b/services/matrix-onboarding-bot/src/onboarding/onboarding.service.ts new file mode 100644 index 000000000..2ea7469ec --- /dev/null +++ b/services/matrix-onboarding-bot/src/onboarding/onboarding.service.ts @@ -0,0 +1,232 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + OnboardingStateMachine, + OnboardingSession, + OnboardingAction, + OnboardingData, + OnboardingState, +} from './state-machine'; + +export interface UserProfile { + displayName?: string; + interests?: string[]; + locale: 'de' | 'en'; + onboardingCompleted: boolean; +} + +interface ManaAuthSettingsResponse { + globalSettings: { + locale: string; + displayName?: string; + interests?: string[]; + onboardingCompleted?: boolean; + nav: unknown; + theme: unknown; + }; +} + +@Injectable() +export class OnboardingService { + private readonly logger = new Logger(OnboardingService.name); + private readonly authUrl: string; + + // In-memory session storage (per Matrix user) + // Key: Matrix user ID (e.g., @user:matrix.org) + private sessions: Map = new Map(); + + constructor(private configService: ConfigService) { + this.authUrl = this.configService.get('manaAuth.url') || 'http://localhost:3001'; + } + + /** + * Get or create onboarding session for a user + */ + getSession(matrixUserId: string): OnboardingSession { + let session = this.sessions.get(matrixUserId); + if (!session) { + session = OnboardingStateMachine.createSession(); + this.sessions.set(matrixUserId, session); + } + return session; + } + + /** + * Process an action and update the session + */ + processAction( + matrixUserId: string, + action: OnboardingAction, + lang: 'de' | 'en' = 'de' + ): { session: OnboardingSession; messageKey: string; messageParams?: Record } { + const session = this.getSession(matrixUserId); + const result = OnboardingStateMachine.transition(session, action, lang); + + // Update session + const updatedSession: OnboardingSession = { + state: result.newState, + data: result.data, + startedAt: session.startedAt, + }; + this.sessions.set(matrixUserId, updatedSession); + + return { + session: updatedSession, + messageKey: result.messageKey, + messageParams: result.messageParams, + }; + } + + /** + * Check if user is in onboarding + */ + isInOnboarding(matrixUserId: string): boolean { + const session = this.sessions.get(matrixUserId); + if (!session) return false; + return OnboardingStateMachine.isInProgress(session.state); + } + + /** + * Get current state + */ + getState(matrixUserId: string): OnboardingState { + const session = this.getSession(matrixUserId); + return session.state; + } + + /** + * Reset session + */ + resetSession(matrixUserId: string): void { + this.sessions.delete(matrixUserId); + } + + /** + * Check if current state can be skipped + */ + canSkip(matrixUserId: string): boolean { + const session = this.getSession(matrixUserId); + return OnboardingStateMachine.canSkip(session.state); + } + + // ============================================================================ + // mana-core-auth API Integration + // ============================================================================ + + /** + * Save onboarding data to mana-core-auth + */ + async saveProfile(token: string, data: OnboardingData): Promise { + try { + const response = await fetch(`${this.authUrl}/api/v1/settings/global`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + displayName: data.displayName, + interests: data.interests, + locale: data.locale, + onboardingCompleted: true, + }), + }); + + if (!response.ok) { + this.logger.error(`Failed to save profile: ${response.status} ${response.statusText}`); + return false; + } + + this.logger.debug('Profile saved successfully'); + return true; + } catch (error) { + this.logger.error('Failed to save profile', error); + return false; + } + } + + /** + * Get user profile from mana-core-auth + */ + async getProfile(token: string): Promise { + try { + const response = await fetch(`${this.authUrl}/api/v1/settings`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + this.logger.error(`Failed to get profile: ${response.status}`); + return null; + } + + const data: ManaAuthSettingsResponse = await response.json(); + const settings = data.globalSettings; + + return { + displayName: settings.displayName, + interests: settings.interests, + locale: (settings.locale as 'de' | 'en') || 'de', + onboardingCompleted: settings.onboardingCompleted || false, + }; + } catch (error) { + this.logger.error('Failed to get profile', error); + return null; + } + } + + /** + * Update a single profile field + */ + async updateProfileField( + token: string, + field: 'displayName' | 'interests' | 'locale', + value: string | string[] + ): Promise { + try { + const body: Record = {}; + + if (field === 'displayName') { + body.displayName = value as string; + } else if (field === 'interests') { + body.interests = Array.isArray(value) + ? value + : (value as string).split(',').map((i) => i.trim()); + } else if (field === 'locale') { + const locale = (value as string).toLowerCase(); + if (locale !== 'de' && locale !== 'en') { + return false; + } + body.locale = locale; + } + + const response = await fetch(`${this.authUrl}/api/v1/settings/global`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + this.logger.error(`Failed to update profile field: ${response.status}`); + return false; + } + + return true; + } catch (error) { + this.logger.error('Failed to update profile field', error); + return false; + } + } + + /** + * Check if user has completed onboarding + */ + async hasCompletedOnboarding(token: string): Promise { + const profile = await this.getProfile(token); + return profile?.onboardingCompleted || false; + } +} diff --git a/services/matrix-onboarding-bot/src/onboarding/state-machine.ts b/services/matrix-onboarding-bot/src/onboarding/state-machine.ts new file mode 100644 index 000000000..dfdb3aac4 --- /dev/null +++ b/services/matrix-onboarding-bot/src/onboarding/state-machine.ts @@ -0,0 +1,362 @@ +/** + * Onboarding State Machine + * + * States: + * - IDLE: Not in onboarding + * - NAME: Asking for display name + * - INTERESTS: Asking for interests (skippable) + * - LANGUAGE: Asking for language preference + * - SUMMARY: Showing summary and asking for confirmation + * - COMPLETED: Onboarding finished + */ + +export type OnboardingState = 'IDLE' | 'NAME' | 'INTERESTS' | 'LANGUAGE' | 'SUMMARY' | 'COMPLETED'; + +export interface OnboardingData { + displayName?: string; + interests?: string[]; + locale?: 'de' | 'en'; +} + +export interface OnboardingSession { + state: OnboardingState; + data: OnboardingData; + startedAt: number; +} + +export type OnboardingAction = + | { type: 'START' } + | { type: 'INPUT'; value: string } + | { type: 'SKIP' } + | { type: 'CONFIRM' } + | { type: 'REJECT' } + | { type: 'RESET' }; + +export interface StateTransitionResult { + newState: OnboardingState; + data: OnboardingData; + message: string; + messageKey: string; + messageParams?: Record; + error?: string; +} + +/** + * Pure state machine - no side effects + */ +export class OnboardingStateMachine { + /** + * Process an action and return the new state + */ + static transition( + session: OnboardingSession, + action: OnboardingAction, + lang: 'de' | 'en' = 'de' + ): StateTransitionResult { + const { state, data } = session; + + switch (state) { + case 'IDLE': + return this.handleIdle(action, data); + + case 'NAME': + return this.handleName(action, data); + + case 'INTERESTS': + return this.handleInterests(action, data); + + case 'LANGUAGE': + return this.handleLanguage(action, data); + + case 'SUMMARY': + return this.handleSummary(action, data, lang); + + case 'COMPLETED': + return this.handleCompleted(action, data); + + default: + return { + newState: 'IDLE', + data, + message: '', + messageKey: 'error', + }; + } + } + + private static handleIdle(action: OnboardingAction, data: OnboardingData): StateTransitionResult { + if (action.type === 'START') { + return { + newState: 'NAME', + data: {}, + message: '', + messageKey: 'askName', + }; + } + return { + newState: 'IDLE', + data, + message: '', + messageKey: 'idle', + }; + } + + private static handleName(action: OnboardingAction, data: OnboardingData): StateTransitionResult { + if (action.type === 'INPUT' && action.value.trim()) { + const displayName = action.value.trim(); + return { + newState: 'INTERESTS', + data: { ...data, displayName }, + message: '', + messageKey: 'askInterests', + messageParams: { name: displayName }, + }; + } + + if (action.type === 'SKIP') { + return { + newState: 'NAME', + data, + message: '', + messageKey: 'skipNotAllowed', + }; + } + + if (action.type === 'RESET') { + return { + newState: 'IDLE', + data: {}, + message: '', + messageKey: 'cancelled', + }; + } + + return { + newState: 'NAME', + data, + message: '', + messageKey: 'askName', + }; + } + + private static handleInterests( + action: OnboardingAction, + data: OnboardingData + ): StateTransitionResult { + if (action.type === 'INPUT' && action.value.trim()) { + const interests = action.value + .split(',') + .map((i) => i.trim()) + .filter((i) => i.length > 0); + + return { + newState: 'LANGUAGE', + data: { ...data, interests }, + message: '', + messageKey: 'askLanguage', + }; + } + + if (action.type === 'SKIP') { + return { + newState: 'LANGUAGE', + data: { ...data, interests: [] }, + message: '', + messageKey: 'askLanguage', + }; + } + + if (action.type === 'RESET') { + return { + newState: 'IDLE', + data: {}, + message: '', + messageKey: 'cancelled', + }; + } + + return { + newState: 'INTERESTS', + data, + message: '', + messageKey: 'askInterests', + messageParams: { name: data.displayName || '' }, + }; + } + + private static handleLanguage( + action: OnboardingAction, + data: OnboardingData + ): StateTransitionResult { + if (action.type === 'INPUT') { + const input = action.value.trim().toLowerCase(); + if (input === 'de' || input === 'en' || input === 'deutsch' || input === 'english') { + const locale = input === 'de' || input === 'deutsch' ? 'de' : 'en'; + return { + newState: 'SUMMARY', + data: { ...data, locale }, + message: '', + messageKey: 'summary', + messageParams: { + name: data.displayName || '-', + interests: data.interests?.length ? data.interests.join(', ') : '-', + language: locale === 'de' ? 'Deutsch' : 'English', + }, + }; + } + return { + newState: 'LANGUAGE', + data, + message: '', + messageKey: 'invalidLanguage', + }; + } + + if (action.type === 'SKIP') { + // Default to 'de' if skipped + return { + newState: 'SUMMARY', + data: { ...data, locale: 'de' }, + message: '', + messageKey: 'summary', + messageParams: { + name: data.displayName || '-', + interests: data.interests?.length ? data.interests.join(', ') : '-', + language: 'Deutsch', + }, + }; + } + + if (action.type === 'RESET') { + return { + newState: 'IDLE', + data: {}, + message: '', + messageKey: 'cancelled', + }; + } + + return { + newState: 'LANGUAGE', + data, + message: '', + messageKey: 'askLanguage', + }; + } + + private static handleSummary( + action: OnboardingAction, + data: OnboardingData, + _lang: 'de' | 'en' + ): StateTransitionResult { + if (action.type === 'CONFIRM' || action.type === 'INPUT') { + const input = action.type === 'INPUT' ? action.value.trim().toLowerCase() : 'yes'; + const isYes = + input === 'ja' || + input === 'yes' || + input === 'j' || + input === 'y' || + input === 'ok' || + input === 'okay'; + const isNo = input === 'nein' || input === 'no' || input === 'n'; + + if (isYes) { + return { + newState: 'COMPLETED', + data, + message: '', + messageKey: 'completed', + }; + } + + if (isNo) { + return { + newState: 'IDLE', + data: {}, + message: '', + messageKey: 'cancelled', + }; + } + + // Neither yes nor no - repeat the question + return { + newState: 'SUMMARY', + data, + message: '', + messageKey: 'summary', + messageParams: { + name: data.displayName || '-', + interests: data.interests?.length ? data.interests.join(', ') : '-', + language: data.locale === 'en' ? 'English' : 'Deutsch', + }, + }; + } + + if (action.type === 'RESET') { + return { + newState: 'IDLE', + data: {}, + message: '', + messageKey: 'cancelled', + }; + } + + return { + newState: 'SUMMARY', + data, + message: '', + messageKey: 'summary', + messageParams: { + name: data.displayName || '-', + interests: data.interests?.length ? data.interests.join(', ') : '-', + language: data.locale === 'en' ? 'English' : 'Deutsch', + }, + }; + } + + private static handleCompleted( + action: OnboardingAction, + data: OnboardingData + ): StateTransitionResult { + if (action.type === 'START') { + return { + newState: 'NAME', + data: {}, + message: '', + messageKey: 'askName', + }; + } + + return { + newState: 'COMPLETED', + data, + message: '', + messageKey: 'alreadyOnboarded', + }; + } + + /** + * Create initial session + */ + static createSession(): OnboardingSession { + return { + state: 'IDLE', + data: {}, + startedAt: Date.now(), + }; + } + + /** + * Check if a state allows skipping + */ + static canSkip(state: OnboardingState): boolean { + return state === 'INTERESTS' || state === 'LANGUAGE'; + } + + /** + * Check if onboarding is in progress + */ + static isInProgress(state: OnboardingState): boolean { + return state !== 'IDLE' && state !== 'COMPLETED'; + } +} diff --git a/services/matrix-onboarding-bot/tsconfig.build.json b/services/matrix-onboarding-bot/tsconfig.build.json new file mode 100644 index 000000000..4491981e0 --- /dev/null +++ b/services/matrix-onboarding-bot/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/services/matrix-onboarding-bot/tsconfig.json b/services/matrix-onboarding-bot/tsconfig.json new file mode 100644 index 000000000..b439390d0 --- /dev/null +++ b/services/matrix-onboarding-bot/tsconfig.json @@ -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 + } +}