mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 15:39:40 +02:00
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:
parent
531ed3e215
commit
a6fc1cb66e
15 changed files with 1456 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
193
services/matrix-onboarding-bot/CLAUDE.md
Normal file
193
services/matrix-onboarding-bot/CLAUDE.md
Normal 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.
|
||||
71
services/matrix-onboarding-bot/Dockerfile
Normal file
71
services/matrix-onboarding-bot/Dockerfile
Normal 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"]
|
||||
43
services/matrix-onboarding-bot/package.json
Normal file
43
services/matrix-onboarding-bot/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
18
services/matrix-onboarding-bot/src/app.module.ts
Normal file
18
services/matrix-onboarding-bot/src/app.module.ts
Normal 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 {}
|
||||
15
services/matrix-onboarding-bot/src/bot/bot.module.ts
Normal file
15
services/matrix-onboarding-bot/src/bot/bot.module.ts
Normal 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 {}
|
||||
364
services/matrix-onboarding-bot/src/bot/matrix.service.ts
Normal file
364
services/matrix-onboarding-bot/src/bot/matrix.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
87
services/matrix-onboarding-bot/src/config/configuration.ts
Normal file
87
services/matrix-onboarding-bot/src/config/configuration.ts
Normal 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.',
|
||||
},
|
||||
};
|
||||
15
services/matrix-onboarding-bot/src/main.ts
Normal file
15
services/matrix-onboarding-bot/src/main.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const port = process.env.PORT || 4020;
|
||||
|
||||
await app.listen(port);
|
||||
|
||||
const logger = new Logger('Bootstrap');
|
||||
logger.log(`Matrix Onboarding Bot running on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
|
||||
@Module({
|
||||
providers: [OnboardingService],
|
||||
exports: [OnboardingService],
|
||||
})
|
||||
export class OnboardingModule {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
362
services/matrix-onboarding-bot/src/onboarding/state-machine.ts
Normal file
362
services/matrix-onboarding-bot/src/onboarding/state-machine.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
4
services/matrix-onboarding-bot/tsconfig.build.json
Normal file
4
services/matrix-onboarding-bot/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
22
services/matrix-onboarding-bot/tsconfig.json
Normal file
22
services/matrix-onboarding-bot/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue