mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 03:39:41 +02:00
✨ 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:
parent
2b979d5548
commit
145b0b6599
20 changed files with 1632 additions and 478 deletions
265
packages/matrix-bot-common/CLAUDE.md
Normal file
265
packages/matrix-bot-common/CLAUDE.md
Normal 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
|
||||
```
|
||||
38
packages/matrix-bot-common/package.json
Normal file
38
packages/matrix-bot-common/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
250
packages/matrix-bot-common/src/base/base-matrix.service.ts
Normal file
250
packages/matrix-bot-common/src/base/base-matrix.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
packages/matrix-bot-common/src/base/index.ts
Normal file
10
packages/matrix-bot-common/src/base/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export { BaseMatrixService } from './base-matrix.service';
|
||||
export {
|
||||
type MatrixBotConfig,
|
||||
type MatrixRoomEvent,
|
||||
type MatrixMessageEvent,
|
||||
isTextMessage,
|
||||
isAudioMessage,
|
||||
isImageMessage,
|
||||
isFileMessage,
|
||||
} from './types';
|
||||
75
packages/matrix-bot-common/src/base/types.ts
Normal file
75
packages/matrix-bot-common/src/base/types.ts
Normal 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';
|
||||
}
|
||||
58
packages/matrix-bot-common/src/health/health.controller.ts
Normal file
58
packages/matrix-bot-common/src/health/health.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
6
packages/matrix-bot-common/src/health/index.ts
Normal file
6
packages/matrix-bot-common/src/health/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
HealthController,
|
||||
HEALTH_SERVICE_NAME,
|
||||
createHealthProvider,
|
||||
type HealthResponse,
|
||||
} from './health.controller';
|
||||
68
packages/matrix-bot-common/src/index.ts
Normal file
68
packages/matrix-bot-common/src/index.ts
Normal 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';
|
||||
6
packages/matrix-bot-common/src/keywords/index.ts
Normal file
6
packages/matrix-bot-common/src/keywords/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
KeywordCommandDetector,
|
||||
COMMON_KEYWORDS,
|
||||
type KeywordCommand,
|
||||
type KeywordDetectorOptions,
|
||||
} from './keyword-detector';
|
||||
118
packages/matrix-bot-common/src/keywords/keyword-detector.ts
Normal file
118
packages/matrix-bot-common/src/keywords/keyword-detector.ts
Normal 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' },
|
||||
];
|
||||
1
packages/matrix-bot-common/src/list-mapper/index.ts
Normal file
1
packages/matrix-bot-common/src/list-mapper/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { UserListMapper, UserIdListMapper } from './list-mapper';
|
||||
113
packages/matrix-bot-common/src/list-mapper/list-mapper.ts
Normal file
113
packages/matrix-bot-common/src/list-mapper/list-mapper.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
6
packages/matrix-bot-common/src/markdown/index.ts
Normal file
6
packages/matrix-bot-common/src/markdown/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export {
|
||||
markdownToHtml,
|
||||
escapeHtml,
|
||||
formatNumberedList,
|
||||
formatBulletList,
|
||||
} from './markdown-formatter';
|
||||
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
5
packages/matrix-bot-common/src/message/index.ts
Normal file
5
packages/matrix-bot-common/src/message/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
MatrixMessageService,
|
||||
type MatrixMessageContent,
|
||||
type SendMessageOptions,
|
||||
} from './message.service';
|
||||
227
packages/matrix-bot-common/src/message/message.service.ts
Normal file
227
packages/matrix-bot-common/src/message/message.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/matrix-bot-common/src/session/index.ts
Normal file
1
packages/matrix-bot-common/src/session/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { SessionHelper, createSessionHelper } from './session-helper';
|
||||
85
packages/matrix-bot-common/src/session/session-helper.ts
Normal file
85
packages/matrix-bot-common/src/session/session-helper.ts
Normal 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);
|
||||
}
|
||||
25
packages/matrix-bot-common/tsconfig.json
Normal file
25
packages/matrix-bot-common/tsconfig.json
Normal 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
700
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue