feat(onboarding): add Matrix onboarding bot for profile setup

- 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 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-14 12:42:41 +01:00
parent 531ed3e215
commit a6fc1cb66e
15 changed files with 1456 additions and 0 deletions

View file

@ -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 {

View file

@ -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

View file

@ -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.

View file

@ -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"]

View file

@ -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"
}
}

View file

@ -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 {}

View file

@ -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 {}

View file

@ -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<string>('matrix.homeserverUrl') || 'http://localhost:8008',
accessToken: this.configService.get<string>('matrix.accessToken') || '',
storagePath:
this.configService.get<string>('matrix.storagePath') || './data/bot-storage.json',
allowedRooms: this.configService.get<string[]>('matrix.allowedRooms') || [],
};
}
protected getIntroductionMessage(): string | null {
return MESSAGES.de.welcome;
}
protected async handleTextMessage(
roomId: string,
event: MatrixRoomEvent,
message: string,
sender: string
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string | null> {
return this.sessionService.getToken(userId);
}
private async getLanguage(userId: string): Promise<Language> {
return this.i18nService.getLanguage(userId);
}
private getMessage(key: string, lang: Language, params?: Record<string, string>): string {
const messages = MESSAGES[lang];
let message = (messages as Record<string, string>)[key] || key;
if (params) {
message = this.formatMessage(message, params);
}
return message;
}
private formatMessage(template: string, params: Record<string, string>): string {
let result = template;
for (const [key, value] of Object.entries(params)) {
result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
}
return result;
}
}

View file

@ -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.',
},
};

View file

@ -0,0 +1,15 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const port = process.env.PORT || 4020;
await app.listen(port);
const logger = new Logger('Bootstrap');
logger.log(`Matrix Onboarding Bot running on port ${port}`);
}
bootstrap();

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { OnboardingService } from './onboarding.service';
@Module({
providers: [OnboardingService],
exports: [OnboardingService],
})
export class OnboardingModule {}

View file

@ -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<string, OnboardingSession> = new Map();
constructor(private configService: ConfigService) {
this.authUrl = this.configService.get<string>('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<string, string> } {
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<boolean> {
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<UserProfile | null> {
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<boolean> {
try {
const body: Record<string, unknown> = {};
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<boolean> {
const profile = await this.getProfile(token);
return profile?.onboardingCompleted || false;
}
}

View file

@ -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<string, string>;
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';
}
}

View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View 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
}
}