- Fix sendThreadMessage room mismatch: use channelId from options instead of hardcoded controlRoomId - Add .catch() to fire-and-forget handleRoomMessage to prevent silent error swallowing - Wrap dispatchJob in try-catch for user-visible error reporting in handleFixCommand - Add MATRIX_BOT_USER_ID validation in connect() to prevent infinite message loops - Fix streamResponse error masking: wrap finally/catch side-effects in try-catch - Replace unsafe type assertion with public getClient() in MatrixRoomService - Add orphaned room warning in provisionRoom on DB failure - Add provider identity to Herald error logs - Add channelId to ThreadMessageOptions interface and all callers - Add missing env var warnings in BridgeModule factory - Fix JSON injection in setup-bot.sh: use jq for safe JSON construction Fixes #377 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
152 lines
4.7 KiB
TypeScript
152 lines
4.7 KiB
TypeScript
import { Injectable, Logger, Optional, Inject } from "@nestjs/common";
|
|
import { PrismaService } from "../../prisma/prisma.service";
|
|
import { MatrixService } from "./matrix.service";
|
|
import type { MatrixClient, RoomCreateOptions } from "matrix-bot-sdk";
|
|
|
|
/**
|
|
* MatrixRoomService - Workspace-to-Matrix-Room mapping and provisioning
|
|
*
|
|
* Responsibilities:
|
|
* - Provision Matrix rooms for Mosaic workspaces
|
|
* - Map workspaces to Matrix room IDs
|
|
* - Link/unlink existing rooms to workspaces
|
|
*
|
|
* Room provisioning creates a private Matrix room with:
|
|
* - Name: "Mosaic: {workspace_name}"
|
|
* - Alias: #mosaic-{workspace_slug}:{server_name}
|
|
* - Room ID stored in workspace.matrixRoomId
|
|
*/
|
|
@Injectable()
|
|
export class MatrixRoomService {
|
|
private readonly logger = new Logger(MatrixRoomService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
@Optional() @Inject(MatrixService) private readonly matrixService: MatrixService | null
|
|
) {}
|
|
|
|
/**
|
|
* Provision a Matrix room for a workspace and store the mapping.
|
|
*
|
|
* @param workspaceId - The workspace UUID
|
|
* @param workspaceName - Human-readable workspace name
|
|
* @param workspaceSlug - URL-safe workspace identifier for the room alias
|
|
* @returns The Matrix room ID, or null if Matrix is not configured
|
|
*/
|
|
async provisionRoom(
|
|
workspaceId: string,
|
|
workspaceName: string,
|
|
workspaceSlug: string
|
|
): Promise<string | null> {
|
|
if (!this.matrixService?.isConnected()) {
|
|
this.logger.warn("Matrix is not configured or not connected; skipping room provisioning");
|
|
return null;
|
|
}
|
|
|
|
const client = this.getMatrixClient();
|
|
if (!client) {
|
|
this.logger.warn("Matrix client is not available; skipping room provisioning");
|
|
return null;
|
|
}
|
|
|
|
const roomOptions: RoomCreateOptions = {
|
|
name: `Mosaic: ${workspaceName}`,
|
|
room_alias_name: `mosaic-${workspaceSlug}`,
|
|
topic: `Mosaic workspace: ${workspaceName}`,
|
|
preset: "private_chat",
|
|
visibility: "private",
|
|
};
|
|
|
|
this.logger.log(
|
|
`Provisioning Matrix room for workspace "${workspaceName}" (${workspaceId})...`
|
|
);
|
|
|
|
const roomId = await client.createRoom(roomOptions);
|
|
|
|
// Store the room mapping
|
|
try {
|
|
await this.prisma.workspace.update({
|
|
where: { id: workspaceId },
|
|
data: { matrixRoomId: roomId },
|
|
});
|
|
} catch (dbError: unknown) {
|
|
this.logger.error(
|
|
`Failed to store room mapping for workspace ${workspaceId}, room ${roomId} may be orphaned: ${dbError instanceof Error ? dbError.message : "unknown"}`
|
|
);
|
|
throw dbError;
|
|
}
|
|
|
|
this.logger.log(`Matrix room ${roomId} provisioned and linked to workspace ${workspaceId}`);
|
|
|
|
return roomId;
|
|
}
|
|
|
|
/**
|
|
* Look up the Matrix room ID mapped to a workspace.
|
|
*
|
|
* @param workspaceId - The workspace UUID
|
|
* @returns The Matrix room ID, or null if no room is mapped
|
|
*/
|
|
async getRoomForWorkspace(workspaceId: string): Promise<string | null> {
|
|
const workspace = await this.prisma.workspace.findUnique({
|
|
where: { id: workspaceId },
|
|
select: { matrixRoomId: true },
|
|
});
|
|
|
|
return workspace?.matrixRoomId ?? null;
|
|
}
|
|
|
|
/**
|
|
* Reverse lookup: find the workspace that owns a given Matrix room.
|
|
*
|
|
* @param roomId - The Matrix room ID (e.g. "!abc:example.com")
|
|
* @returns The workspace ID, or null if the room is not mapped to any workspace
|
|
*/
|
|
async getWorkspaceForRoom(roomId: string): Promise<string | null> {
|
|
const workspace = await this.prisma.workspace.findFirst({
|
|
where: { matrixRoomId: roomId },
|
|
select: { id: true },
|
|
});
|
|
|
|
return workspace?.id ?? null;
|
|
}
|
|
|
|
/**
|
|
* Manually link an existing Matrix room to a workspace.
|
|
*
|
|
* @param workspaceId - The workspace UUID
|
|
* @param roomId - The Matrix room ID to link
|
|
*/
|
|
async linkWorkspaceToRoom(workspaceId: string, roomId: string): Promise<void> {
|
|
await this.prisma.workspace.update({
|
|
where: { id: workspaceId },
|
|
data: { matrixRoomId: roomId },
|
|
});
|
|
|
|
this.logger.log(`Linked workspace ${workspaceId} to Matrix room ${roomId}`);
|
|
}
|
|
|
|
/**
|
|
* Remove the Matrix room mapping from a workspace.
|
|
*
|
|
* @param workspaceId - The workspace UUID
|
|
*/
|
|
async unlinkWorkspace(workspaceId: string): Promise<void> {
|
|
await this.prisma.workspace.update({
|
|
where: { id: workspaceId },
|
|
data: { matrixRoomId: null },
|
|
});
|
|
|
|
this.logger.log(`Unlinked Matrix room from workspace ${workspaceId}`);
|
|
}
|
|
|
|
/**
|
|
* Access the underlying MatrixClient from the MatrixService
|
|
* via the public getClient() accessor.
|
|
*/
|
|
private getMatrixClient(): MatrixClient | null {
|
|
if (!this.matrixService) return null;
|
|
return this.matrixService.getClient();
|
|
}
|
|
}
|