feat: create @manacore/matrix-bot-common shared package

New package with shared utilities for Matrix bots:

**Components:**
- `BaseMatrixService` - Abstract base class with client lifecycle
- `HealthController` - Standardized health endpoint
- `MatrixMessageService` - Message/reply/reaction helpers
- `markdownToHtml` - Markdown to HTML conversion
- `KeywordCommandDetector` - Natural language command detection
- `SessionHelper<T>` - Type-safe session data wrapper
- `UserListMapper<T>` - Number-based reference system

**Estimated Impact:**
- ~4,000 lines of duplicate code can be eliminated
- 19 Matrix bots can use these shared utilities
- Consistent behavior across all bots

Documentation in packages/matrix-bot-common/CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-01 01:02:55 +01:00
parent 2b979d5548
commit 145b0b6599
20 changed files with 1632 additions and 478 deletions

View file

@ -0,0 +1,265 @@
# @manacore/matrix-bot-common
Shared utilities and base classes for Matrix bots.
## Purpose
This package consolidates common code patterns found across all 19 Matrix bots:
- ~4,000 lines of duplicate code reduced to shared utilities
- Consistent behavior across all bots
- Easier maintenance and updates
- Type-safe helpers for common patterns
## Available Components
### BaseMatrixService
Abstract base class that handles Matrix client lifecycle:
```typescript
import { BaseMatrixService, MatrixBotConfig, MatrixRoomEvent } from '@manacore/matrix-bot-common';
@Injectable()
export class MyBotService extends BaseMatrixService {
constructor(configService: ConfigService) {
super(configService);
}
protected getConfig(): MatrixBotConfig {
return {
homeserverUrl: this.configService.get('matrix.homeserverUrl'),
accessToken: this.configService.get('matrix.accessToken'),
storagePath: this.configService.get('matrix.storagePath'),
allowedRooms: this.configService.get('matrix.allowedRooms') || [],
};
}
protected async handleTextMessage(
roomId: string,
event: MatrixRoomEvent,
message: string,
sender: string
) {
if (message === '!hello') {
await this.sendReply(roomId, event, 'Hello!');
}
}
// Optional: Handle voice messages
protected async handleAudioMessage(roomId: string, event: MatrixRoomEvent, sender: string) {
// Transcribe and process
}
// Optional: Send intro on room join
protected getIntroductionMessage(): string | null {
return 'Hello! I am a bot.';
}
}
```
**Provides:**
- `onModuleInit()` - Client setup, storage, auto-join
- `onModuleDestroy()` - Graceful shutdown
- `sendMessage(roomId, message)` - Send markdown message
- `sendReply(roomId, event, message)` - Reply to event
- `sendNotice(roomId, message)` - Non-highlighted message
- `downloadMedia(mxcUrl)` - Download from Matrix
- `uploadMedia(buffer, contentType, filename)` - Upload to Matrix
### HealthController
Shared health endpoint:
```typescript
import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common';
@Module({
controllers: [HealthController],
providers: [createHealthProvider('matrix-todo-bot')],
})
export class AppModule {}
```
Returns:
```json
{
"status": "ok",
"service": "matrix-todo-bot",
"timestamp": "2026-02-01T12:00:00.000Z",
"uptime": 3600
}
```
### MatrixMessageService
Injectable service for message operations:
```typescript
import { MatrixMessageService } from '@manacore/matrix-bot-common';
@Injectable()
export class MyService {
constructor(private messageService: MatrixMessageService) {}
async doSomething(client: MatrixClient, roomId: string) {
await this.messageService.sendMessage(client, roomId, '**Bold** message');
await this.messageService.sendReaction(client, roomId, eventId, '👍');
await this.messageService.editMessage(client, roomId, eventId, 'Updated text');
}
}
```
### KeywordCommandDetector
Natural language command detection:
```typescript
import { KeywordCommandDetector, COMMON_KEYWORDS } from '@manacore/matrix-bot-common';
const detector = new KeywordCommandDetector([
...COMMON_KEYWORDS, // hilfe, help, status, etc.
{ keywords: ['liste', 'list', 'zeige'], command: 'list' },
{ keywords: ['neu', 'new', 'erstelle'], command: 'create' },
]);
const command = detector.detect('zeige mir alles'); // Returns 'list'
const command2 = detector.detect('random text'); // Returns null
```
### Markdown Utilities
```typescript
import { markdownToHtml, formatNumberedList, formatBulletList } from '@manacore/matrix-bot-common';
const html = markdownToHtml('**bold** and *italic*');
// '<strong>bold</strong> and <em>italic</em>'
const list = formatNumberedList(items, (item, i) => `${item.name} - ${item.status}`);
// '1. Item A - active\n2. Item B - done'
```
### SessionHelper
Type-safe session data wrapper:
```typescript
import { SessionHelper } from '@manacore/matrix-bot-common';
interface MySessionData {
currentItemId: string;
selectedModel: string;
itemList: string[];
}
const session = new SessionHelper<MySessionData>(sessionService, matrixUserId);
session.set('currentItemId', 'abc123');
const itemId = session.get('currentItemId'); // string | null
session.delete('currentItemId');
if (session.isLoggedIn()) {
const token = session.getToken();
}
```
### UserListMapper
Number-based reference system:
```typescript
import { UserListMapper } from '@manacore/matrix-bot-common';
const mapper = new UserListMapper<Contact>();
// After listing contacts to user
mapper.setList(userId, contacts);
// User says "!select 3"
const contact = mapper.getByNumber(userId, 3);
// For items with id field
import { UserIdListMapper } from '@manacore/matrix-bot-common';
const idMapper = new UserIdListMapper<{ id: string; name: string }>();
const itemId = idMapper.getIdByNumber(userId, 2);
```
## Migration Guide
### Before (duplicate code in each bot)
```typescript
// matrix.service.ts - 50+ lines duplicated across 12 bots
private markdownToHtml(text: string): string {
return text
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// ...
}
private async sendReply(roomId: string, event: any, message: string) {
const reply = RichReply.createFor(roomId, event, message, this.markdownToHtml(message));
reply.msgtype = 'm.text';
await this.client.sendMessage(roomId, reply);
}
```
### After (using shared package)
```typescript
import { markdownToHtml } from '@manacore/matrix-bot-common';
// Or extend BaseMatrixService which includes sendReply()
await this.sendReply(roomId, event, message);
```
## Installation
```bash
pnpm --filter matrix-xxx-bot add @manacore/matrix-bot-common
```
## File Structure
```
packages/matrix-bot-common/
├── src/
│ ├── index.ts # Main exports
│ ├── base/
│ │ ├── base-matrix.service.ts
│ │ ├── types.ts
│ │ └── index.ts
│ ├── health/
│ │ ├── health.controller.ts
│ │ └── index.ts
│ ├── message/
│ │ ├── message.service.ts
│ │ └── index.ts
│ ├── markdown/
│ │ ├── markdown-formatter.ts
│ │ └── index.ts
│ ├── keywords/
│ │ ├── keyword-detector.ts
│ │ └── index.ts
│ ├── session/
│ │ ├── session-helper.ts
│ │ └── index.ts
│ └── list-mapper/
│ ├── list-mapper.ts
│ └── index.ts
├── package.json
├── tsconfig.json
└── CLAUDE.md
```
## Development
```bash
# Type check
pnpm --filter @manacore/matrix-bot-common type-check
# Add to a bot
pnpm --filter matrix-xxx-bot add @manacore/matrix-bot-common
```

View file

@ -0,0 +1,38 @@
{
"name": "@manacore/matrix-bot-common",
"version": "0.1.0",
"private": true,
"description": "Shared utilities and base classes for Matrix bots",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./base": "./src/base/index.ts",
"./health": "./src/health/index.ts",
"./message": "./src/message/index.ts",
"./markdown": "./src/markdown/index.ts",
"./keywords": "./src/keywords/index.ts",
"./session": "./src/session/index.ts",
"./list-mapper": "./src/list-mapper/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"clean": "rm -rf dist",
"lint": "eslint ."
},
"dependencies": {
"@manacore/bot-services": "workspace:*",
"@nestjs/common": "^11.0.20",
"@nestjs/config": "^4.0.2",
"matrix-bot-sdk": "^0.7.1"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/config": "^3.0.0 || ^4.0.0",
"matrix-bot-sdk": "^0.7.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,250 @@
import { Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
MatrixClient,
SimpleFsStorageProvider,
AutojoinRoomsMixin,
RichReply,
} from 'matrix-bot-sdk';
import * as path from 'path';
import * as fs from 'fs';
import { MatrixBotConfig, MatrixRoomEvent, isTextMessage, isAudioMessage } from './types';
import { markdownToHtml } from '../markdown/markdown-formatter';
/**
* Abstract base class for Matrix bot services
*
* Provides common functionality:
* - Matrix client initialization
* - Room join handling
* - Message routing
* - Markdown message sending
* - Graceful shutdown
*
* @example
* ```typescript
* @Injectable()
* export class MyBotService extends BaseMatrixService {
* protected async handleTextMessage(roomId: string, event: MatrixRoomEvent, message: string) {
* if (message.startsWith('!hello')) {
* await this.sendReply(roomId, event, 'Hello!');
* }
* }
*
* protected getConfig(): MatrixBotConfig {
* return {
* homeserverUrl: this.configService.get('matrix.homeserverUrl'),
* accessToken: this.configService.get('matrix.accessToken'),
* storagePath: this.configService.get('matrix.storagePath'),
* allowedRooms: this.configService.get('matrix.allowedRooms'),
* };
* }
* }
* ```
*/
export abstract class BaseMatrixService implements OnModuleInit, OnModuleDestroy {
protected readonly logger = new Logger(this.constructor.name);
protected client!: MatrixClient;
protected botUserId: string = '';
protected readonly allowedRooms: string[];
constructor(protected configService: ConfigService) {
this.allowedRooms = this.getConfig().allowedRooms;
}
/**
* Get Matrix configuration - must be implemented by subclass
*/
protected abstract getConfig(): MatrixBotConfig;
/**
* Handle a text message - must be implemented by subclass
*/
protected abstract handleTextMessage(
roomId: string,
event: MatrixRoomEvent,
message: string,
sender: string
): Promise<void>;
/**
* Handle an audio message (optional override)
*/
protected async handleAudioMessage(
_roomId: string,
_event: MatrixRoomEvent,
_sender: string
): Promise<void> {
// Default: no-op, override in subclass for voice support
}
/**
* Get welcome/introduction message (optional override)
*/
protected getIntroductionMessage(): string | null {
return null;
}
/**
* Initialize the Matrix client
*/
async onModuleInit(): Promise<void> {
const config = this.getConfig();
if (!config.accessToken) {
this.logger.error('MATRIX_ACCESS_TOKEN is required');
return;
}
// Ensure storage directory exists
const storageDir = path.dirname(config.storagePath);
if (!fs.existsSync(storageDir)) {
fs.mkdirSync(storageDir, { recursive: true });
this.logger.log(`Created storage directory: ${storageDir}`);
}
// Initialize client
const storage = new SimpleFsStorageProvider(config.storagePath);
this.client = new MatrixClient(config.homeserverUrl, config.accessToken, storage);
// Setup auto-join for allowed rooms
AutojoinRoomsMixin.setupOnClient(this.client);
// Get bot user ID
this.botUserId = await this.client.getUserId();
this.logger.log(`Bot user ID: ${this.botUserId}`);
// Setup room join handler
this.client.on('room.join', async (roomId: string) => {
await this.onRoomJoin(roomId);
});
// Setup message handler
this.client.on('room.message', async (roomId: string, event: MatrixRoomEvent) => {
await this.onRoomMessage(roomId, event);
});
// Start the client
await this.client.start();
this.logger.log('Matrix client started');
}
/**
* Graceful shutdown
*/
async onModuleDestroy(): Promise<void> {
if (this.client) {
await this.client.stop();
this.logger.log('Matrix client stopped');
}
}
/**
* Handle room join event
*/
protected async onRoomJoin(roomId: string): Promise<void> {
this.logger.log(`Joined room: ${roomId}`);
// Send introduction message if defined
const intro = this.getIntroductionMessage();
if (intro) {
await this.sendMessage(roomId, intro);
}
}
/**
* Handle incoming room message
*/
protected async onRoomMessage(roomId: string, event: MatrixRoomEvent): Promise<void> {
// Ignore own messages
if (event.sender === this.botUserId) return;
// Check room permissions
if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) {
return;
}
try {
if (isTextMessage(event)) {
const message = event.content.body.trim();
await this.handleTextMessage(roomId, event, message, event.sender);
} else if (isAudioMessage(event)) {
await this.handleAudioMessage(roomId, event, event.sender);
}
} catch (error) {
this.logger.error(`Error handling message: ${error}`);
await this.sendReply(roomId, event, '❌ Ein Fehler ist aufgetreten.');
}
}
/**
* Send a message to a room
*/
protected async sendMessage(roomId: string, message: string): Promise<string> {
return this.client.sendMessage(roomId, {
msgtype: 'm.text',
body: message,
format: 'org.matrix.custom.html',
formatted_body: markdownToHtml(message),
});
}
/**
* Send a reply to an event
*/
protected async sendReply(
roomId: string,
event: MatrixRoomEvent,
message: string
): Promise<string> {
const reply = RichReply.createFor(roomId, event, message, markdownToHtml(message));
reply.msgtype = 'm.text';
return this.client.sendMessage(roomId, reply);
}
/**
* Send a notice (non-highlighted message)
*/
protected async sendNotice(roomId: string, message: string): Promise<string> {
return this.client.sendMessage(roomId, {
msgtype: 'm.notice',
body: message,
format: 'org.matrix.custom.html',
formatted_body: markdownToHtml(message),
});
}
/**
* Download media from Matrix
*/
protected async downloadMedia(mxcUrl: string): Promise<Buffer> {
const result = await this.client.downloadContent(mxcUrl);
return result.data;
}
/**
* Upload media to Matrix
*/
protected async uploadMedia(
buffer: Buffer,
contentType: string,
filename: string
): Promise<string> {
return this.client.uploadContent(buffer, contentType, filename);
}
/**
* Get the Matrix client (for advanced operations)
*/
protected getClient(): MatrixClient {
return this.client;
}
/**
* Check if a room is allowed
*/
protected isRoomAllowed(roomId: string): boolean {
if (this.allowedRooms.length === 0) return true;
return this.allowedRooms.includes(roomId);
}
}

View file

@ -0,0 +1,10 @@
export { BaseMatrixService } from './base-matrix.service';
export {
type MatrixBotConfig,
type MatrixRoomEvent,
type MatrixMessageEvent,
isTextMessage,
isAudioMessage,
isImageMessage,
isFileMessage,
} from './types';

View file

@ -0,0 +1,75 @@
/**
* Matrix bot configuration
*/
export interface MatrixBotConfig {
/** Matrix homeserver URL */
homeserverUrl: string;
/** Bot access token */
accessToken: string;
/** Path to store bot state */
storagePath: string;
/** Allowed room IDs (empty = all rooms) */
allowedRooms: string[];
}
/**
* Matrix room event
*/
export interface MatrixRoomEvent {
event_id: string;
type: string;
sender: string;
room_id: string;
origin_server_ts: number;
content: {
msgtype?: string;
body?: string;
format?: string;
formatted_body?: string;
url?: string;
info?: Record<string, unknown>;
'm.relates_to'?: {
'm.in_reply_to'?: { event_id: string };
rel_type?: string;
event_id?: string;
};
};
}
/**
* Matrix message event (subset of room event)
*/
export interface MatrixMessageEvent extends MatrixRoomEvent {
content: MatrixRoomEvent['content'] & {
msgtype: string;
body: string;
};
}
/**
* Check if event is a text message
*/
export function isTextMessage(event: MatrixRoomEvent): event is MatrixMessageEvent {
return event.content?.msgtype === 'm.text' && typeof event.content?.body === 'string';
}
/**
* Check if event is an audio message
*/
export function isAudioMessage(event: MatrixRoomEvent): boolean {
return event.content?.msgtype === 'm.audio';
}
/**
* Check if event is an image message
*/
export function isImageMessage(event: MatrixRoomEvent): boolean {
return event.content?.msgtype === 'm.image';
}
/**
* Check if event is a file message
*/
export function isFileMessage(event: MatrixRoomEvent): boolean {
return event.content?.msgtype === 'm.file';
}

View file

@ -0,0 +1,58 @@
import { Controller, Get, Inject, Optional } from '@nestjs/common';
export const HEALTH_SERVICE_NAME = 'HEALTH_SERVICE_NAME';
export interface HealthResponse {
status: 'ok' | 'error';
service: string;
timestamp: string;
uptime?: number;
version?: string;
}
/**
* Shared health controller for Matrix bots
*
* Returns standardized health check response.
*
* @example
* ```typescript
* // In app.module.ts
* @Module({
* controllers: [HealthController],
* providers: [
* { provide: HEALTH_SERVICE_NAME, useValue: 'matrix-todo-bot' }
* ],
* })
* ```
*/
@Controller('health')
export class HealthController {
private readonly startTime = Date.now();
constructor(
@Optional()
@Inject(HEALTH_SERVICE_NAME)
private readonly serviceName?: string
) {}
@Get()
check(): HealthResponse {
return {
status: 'ok',
service: this.serviceName || 'matrix-bot',
timestamp: new Date().toISOString(),
uptime: Math.floor((Date.now() - this.startTime) / 1000),
};
}
}
/**
* Create a health controller provider with service name
*/
export function createHealthProvider(serviceName: string) {
return {
provide: HEALTH_SERVICE_NAME,
useValue: serviceName,
};
}

View file

@ -0,0 +1,6 @@
export {
HealthController,
HEALTH_SERVICE_NAME,
createHealthProvider,
type HealthResponse,
} from './health.controller';

View file

@ -0,0 +1,68 @@
/**
* @manacore/matrix-bot-common
*
* Shared utilities and base classes for Matrix bots.
* Reduces code duplication across 19 Matrix bots.
*
* @example
* ```typescript
* import {
* BaseMatrixService,
* HealthController,
* MatrixMessageService,
* KeywordCommandDetector,
* markdownToHtml,
* SessionHelper,
* UserListMapper,
* } from '@manacore/matrix-bot-common';
* ```
*/
// Base Matrix Service
export {
BaseMatrixService,
type MatrixBotConfig,
type MatrixRoomEvent,
type MatrixMessageEvent,
isTextMessage,
isAudioMessage,
isImageMessage,
isFileMessage,
} from './base';
// Health Controller
export {
HealthController,
HEALTH_SERVICE_NAME,
createHealthProvider,
type HealthResponse,
} from './health';
// Message Service
export {
MatrixMessageService,
type MatrixMessageContent,
type SendMessageOptions,
} from './message';
// Markdown Utilities
export {
markdownToHtml,
escapeHtml,
formatNumberedList,
formatBulletList,
} from './markdown';
// Keyword Detection
export {
KeywordCommandDetector,
COMMON_KEYWORDS,
type KeywordCommand,
type KeywordDetectorOptions,
} from './keywords';
// Session Helper
export { SessionHelper, createSessionHelper } from './session';
// List Mapper
export { UserListMapper, UserIdListMapper } from './list-mapper';

View file

@ -0,0 +1,6 @@
export {
KeywordCommandDetector,
COMMON_KEYWORDS,
type KeywordCommand,
type KeywordDetectorOptions,
} from './keyword-detector';

View file

@ -0,0 +1,118 @@
/**
* Keyword command mapping
*/
export interface KeywordCommand {
/** Keywords that trigger this command (lowercase) */
keywords: string[];
/** Command name to return when matched */
command: string;
}
/**
* Options for keyword detection
*/
export interface KeywordDetectorOptions {
/** Maximum message length to check (default: 60) */
maxLength?: number;
/** Whether to match partial words (default: false) */
partialMatch?: boolean;
}
/**
* Detect commands from natural language keywords
*
* Used by Matrix bots to respond to natural language instead of just !commands.
*
* @example
* ```typescript
* const detector = new KeywordCommandDetector([
* { keywords: ['hilfe', 'help'], command: 'help' },
* { keywords: ['status', 'info'], command: 'status' },
* ]);
*
* detector.detect('hilfe bitte'); // Returns 'help'
* detector.detect('was ist los'); // Returns null
* ```
*/
export class KeywordCommandDetector {
private readonly maxLength: number;
private readonly partialMatch: boolean;
constructor(
private readonly commands: KeywordCommand[],
options: KeywordDetectorOptions = {}
) {
this.maxLength = options.maxLength ?? 60;
this.partialMatch = options.partialMatch ?? false;
}
/**
* Detect a command from a message
*
* @param message - The user's message
* @returns The command name if matched, null otherwise
*/
detect(message: string): string | null {
const lowerMessage = message.toLowerCase().trim();
// Skip long messages (likely not commands)
if (lowerMessage.length > this.maxLength) {
return null;
}
for (const { keywords, command } of this.commands) {
for (const keyword of keywords) {
if (this.matches(lowerMessage, keyword)) {
return command;
}
}
}
return null;
}
/**
* Check if message matches a keyword
*/
private matches(message: string, keyword: string): boolean {
// Exact match
if (message === keyword) {
return true;
}
// Message starts with keyword followed by space
if (message.startsWith(keyword + ' ')) {
return true;
}
// Partial match (keyword appears anywhere)
if (this.partialMatch && message.includes(keyword)) {
return true;
}
return false;
}
/**
* Add more commands dynamically
*/
addCommands(commands: KeywordCommand[]): void {
this.commands.push(...commands);
}
/**
* Get all registered commands
*/
getCommands(): KeywordCommand[] {
return [...this.commands];
}
}
/**
* Common German/English keywords used across bots
*/
export const COMMON_KEYWORDS: KeywordCommand[] = [
{ keywords: ['hilfe', 'help', 'befehle', 'commands', '?'], command: 'help' },
{ keywords: ['status', 'info'], command: 'status' },
{ keywords: ['abbrechen', 'cancel', 'stop'], command: 'cancel' },
];

View file

@ -0,0 +1 @@
export { UserListMapper, UserIdListMapper } from './list-mapper';

View file

@ -0,0 +1,113 @@
/**
* User list mapper for number-based reference system
*
* Allows users to reference items by number after listing them.
* Used by Matrix bots for commands like "!select 3" or "!delete 2".
*
* @example
* ```typescript
* const mapper = new UserListMapper<Contact>();
*
* // After showing a list to the user
* mapper.setList('@user:matrix.org', contacts);
*
* // User says "!select 3"
* const contact = mapper.getByNumber('@user:matrix.org', 3);
* ```
*/
export class UserListMapper<T> {
private lists: Map<string, T[]> = new Map();
/**
* Store a list for a user
*/
setList(userId: string, items: T[]): void {
this.lists.set(userId, [...items]);
}
/**
* Get an item by its 1-based number
*
* @param userId - The user ID
* @param number - 1-based index (as shown to user)
* @returns The item or null if invalid
*/
getByNumber(userId: string, number: number): T | null {
const items = this.lists.get(userId);
if (!items || number < 1 || number > items.length) {
return null;
}
return items[number - 1];
}
/**
* Get the full list for a user
*/
getList(userId: string): T[] {
return this.lists.get(userId) || [];
}
/**
* Check if user has a stored list
*/
hasList(userId: string): boolean {
return this.lists.has(userId) && this.lists.get(userId)!.length > 0;
}
/**
* Get the count of items in user's list
*/
getCount(userId: string): number {
return this.lists.get(userId)?.length || 0;
}
/**
* Clear the list for a user
*/
clearList(userId: string): void {
this.lists.delete(userId);
}
/**
* Clear all lists
*/
clearAll(): void {
this.lists.clear();
}
}
/**
* Extended list mapper that also stores IDs separately
* Useful when items have an id field that needs quick lookup
*/
export class UserIdListMapper<T extends { id: string }> extends UserListMapper<T> {
private idMaps: Map<string, Map<number, string>> = new Map();
override setList(userId: string, items: T[]): void {
super.setList(userId, items);
// Build ID map
const idMap = new Map<number, string>();
items.forEach((item, index) => {
idMap.set(index + 1, item.id);
});
this.idMaps.set(userId, idMap);
}
/**
* Get just the ID by number (without loading full item)
*/
getIdByNumber(userId: string, number: number): string | null {
return this.idMaps.get(userId)?.get(number) || null;
}
override clearList(userId: string): void {
super.clearList(userId);
this.idMaps.delete(userId);
}
override clearAll(): void {
super.clearAll();
this.idMaps.clear();
}
}

View file

@ -0,0 +1,6 @@
export {
markdownToHtml,
escapeHtml,
formatNumberedList,
formatBulletList,
} from './markdown-formatter';

View file

@ -0,0 +1,53 @@
/**
* Convert Markdown text to HTML for Matrix messages
*
* Supports:
* - **bold** -> <strong>bold</strong>
* - *italic* -> <em>italic</em>
* - ~~strikethrough~~ -> <del>strikethrough</del>
* - `code` -> <code>code</code>
* - Newlines -> <br>
*
* @example
* ```typescript
* const html = markdownToHtml('**Hello** *world*');
* // Returns: '<strong>Hello</strong> <em>world</em>'
* ```
*/
export function markdownToHtml(text: string): string {
return text
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/~~(.+?)~~/g, '<del>$1</del>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>');
}
/**
* Escape HTML special characters to prevent XSS
*/
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* Format a list of items as numbered markdown list
*/
export function formatNumberedList<T>(
items: T[],
formatter: (item: T, index: number) => string
): string {
return items.map((item, i) => `${i + 1}. ${formatter(item, i)}`).join('\n');
}
/**
* Format a list of items as bullet markdown list
*/
export function formatBulletList<T>(items: T[], formatter: (item: T) => string): string {
return items.map((item) => `${formatter(item)}`).join('\n');
}

View file

@ -0,0 +1,5 @@
export {
MatrixMessageService,
type MatrixMessageContent,
type SendMessageOptions,
} from './message.service';

View file

@ -0,0 +1,227 @@
import { Injectable, Logger } from '@nestjs/common';
import { MatrixClient, RichReply } from 'matrix-bot-sdk';
import { markdownToHtml } from '../markdown/markdown-formatter';
/**
* Message content for Matrix
*/
export interface MatrixMessageContent {
msgtype: string;
body: string;
format?: string;
formatted_body?: string;
'm.relates_to'?: {
'm.in_reply_to'?: { event_id: string };
event_id?: string;
rel_type?: string;
};
}
/**
* Options for sending messages
*/
export interface SendMessageOptions {
/** Convert markdown to HTML (default: true) */
markdown?: boolean;
/** Message type (default: 'm.text') */
msgtype?: string;
}
/**
* Shared message service for Matrix bots
*
* Provides standardized methods for sending messages, replies, and reactions.
*
* @example
* ```typescript
* const messageService = new MatrixMessageService();
*
* // Send a simple message
* await messageService.sendMessage(client, roomId, 'Hello!');
*
* // Send a reply to an event
* await messageService.sendReply(client, roomId, event, 'Thanks!');
*
* // Send a reaction
* await messageService.sendReaction(client, roomId, eventId, '👍');
* ```
*/
@Injectable()
export class MatrixMessageService {
private readonly logger = new Logger(MatrixMessageService.name);
/**
* Send a message to a room
*/
async sendMessage(
client: MatrixClient,
roomId: string,
message: string,
options: SendMessageOptions = {}
): Promise<string> {
const { markdown = true, msgtype = 'm.text' } = options;
const content: MatrixMessageContent = {
msgtype,
body: message,
};
if (markdown) {
content.format = 'org.matrix.custom.html';
content.formatted_body = markdownToHtml(message);
}
return client.sendMessage(roomId, content);
}
/**
* Send a reply to a specific event
*/
async sendReply(
client: MatrixClient,
roomId: string,
event: { event_id: string; content?: { body?: string } },
message: string,
options: SendMessageOptions = {}
): Promise<string> {
const { markdown = true, msgtype = 'm.text' } = options;
const htmlMessage = markdown ? markdownToHtml(message) : message;
const reply = RichReply.createFor(roomId, event, message, htmlMessage);
reply.msgtype = msgtype;
return client.sendMessage(roomId, reply);
}
/**
* Send a reaction to an event
*/
async sendReaction(
client: MatrixClient,
roomId: string,
eventId: string,
emoji: string
): Promise<string> {
return client.sendEvent(roomId, 'm.reaction', {
'm.relates_to': {
rel_type: 'm.annotation',
event_id: eventId,
key: emoji,
},
});
}
/**
* Send a notice (non-highlighted message)
*/
async sendNotice(
client: MatrixClient,
roomId: string,
message: string,
options: Omit<SendMessageOptions, 'msgtype'> = {}
): Promise<string> {
return this.sendMessage(client, roomId, message, { ...options, msgtype: 'm.notice' });
}
/**
* Send an image to a room
*/
async sendImage(
client: MatrixClient,
roomId: string,
mxcUrl: string,
filename: string,
info?: { w?: number; h?: number; mimetype?: string; size?: number }
): Promise<string> {
return client.sendMessage(roomId, {
msgtype: 'm.image',
body: filename,
url: mxcUrl,
info: info || {},
});
}
/**
* Send a file to a room
*/
async sendFile(
client: MatrixClient,
roomId: string,
mxcUrl: string,
filename: string,
info?: { mimetype?: string; size?: number }
): Promise<string> {
return client.sendMessage(roomId, {
msgtype: 'm.file',
body: filename,
url: mxcUrl,
info: info || {},
});
}
/**
* Edit an existing message
*/
async editMessage(
client: MatrixClient,
roomId: string,
originalEventId: string,
newMessage: string,
options: SendMessageOptions = {}
): Promise<string> {
const { markdown = true, msgtype = 'm.text' } = options;
const content: MatrixMessageContent = {
msgtype,
body: `* ${newMessage}`,
'm.relates_to': {
rel_type: 'm.replace',
event_id: originalEventId,
},
};
if (markdown) {
content.format = 'org.matrix.custom.html';
content.formatted_body = `* ${markdownToHtml(newMessage)}`;
}
return client.sendMessage(roomId, {
...content,
'm.new_content': {
msgtype,
body: newMessage,
format: markdown ? 'org.matrix.custom.html' : undefined,
formatted_body: markdown ? markdownToHtml(newMessage) : undefined,
},
});
}
/**
* Set room topic
*/
async setRoomTopic(client: MatrixClient, roomId: string, topic: string): Promise<void> {
await client.sendStateEvent(roomId, 'm.room.topic', '', { topic });
}
/**
* Pin a message in a room
*/
async pinMessage(client: MatrixClient, roomId: string, eventId: string): Promise<void> {
try {
// Get current pinned events
const pinnedEvents = await client
.getRoomStateEvent(roomId, 'm.room.pinned_events', '')
.catch(() => ({ pinned: [] }));
const pinned: string[] = pinnedEvents?.pinned || [];
// Add new event if not already pinned
if (!pinned.includes(eventId)) {
pinned.push(eventId);
await client.sendStateEvent(roomId, 'm.room.pinned_events', '', { pinned });
}
} catch (error) {
this.logger.warn(`Failed to pin message: ${error}`);
}
}
}

View file

@ -0,0 +1 @@
export { SessionHelper, createSessionHelper } from './session-helper';

View file

@ -0,0 +1,85 @@
import { SessionService } from '@manacore/bot-services';
/**
* Typed session helper for bot-specific session data
*
* Provides type-safe access to session data stored in SessionService.
*
* @example
* ```typescript
* interface ChatSessionData {
* currentConversationId: string;
* selectedModelId: string;
* conversationList: string[];
* }
*
* const session = new SessionHelper<ChatSessionData>(sessionService, matrixUserId);
* session.set('currentConversationId', 'abc123');
* const convId = session.get('currentConversationId'); // string | null
* ```
*/
export class SessionHelper<T extends Record<string, unknown>> {
constructor(
private readonly sessionService: SessionService,
private readonly userId: string
) {}
/**
* Set a session value
*/
set<K extends keyof T>(key: K, value: T[K]): void {
this.sessionService.setSessionData(this.userId, key as string, value);
}
/**
* Get a session value
*/
get<K extends keyof T>(key: K): T[K] | null {
return this.sessionService.getSessionData<T[K]>(this.userId, key as string);
}
/**
* Delete a session value
*/
delete<K extends keyof T>(key: K): void {
this.sessionService.setSessionData(this.userId, key as string, null);
}
/**
* Check if a session value exists
*/
has<K extends keyof T>(key: K): boolean {
return this.get(key) !== null;
}
/**
* Get the underlying user ID
*/
getUserId(): string {
return this.userId;
}
/**
* Check if user is logged in
*/
isLoggedIn(): boolean {
return this.sessionService.isLoggedIn(this.userId);
}
/**
* Get JWT token for API calls
*/
getToken(): string | null {
return this.sessionService.getToken(this.userId);
}
}
/**
* Factory function to create session helper
*/
export function createSessionHelper<T extends Record<string, unknown>>(
sessionService: SessionService,
userId: string
): SessionHelper<T> {
return new SessionHelper<T>(sessionService, userId);
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

700
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff