From 771ed484e4567a473d8b4c79bf271706c2987e3f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Feb 2026 02:18:55 -0600 Subject: [PATCH] feat(#379): Register MatrixService in BridgeModule with conditional loading - Add CHAT_PROVIDERS injection token for bridge-agnostic access - Conditional loading based on env vars (DISCORD_BOT_TOKEN, MATRIX_ACCESS_TOKEN) - Both bridges can run simultaneously - No crash if neither bridge is configured - Tests verify all configuration combinations Refs #379 --- apps/api/src/bridge/bridge.constants.ts | 15 ++ apps/api/src/bridge/bridge.module.spec.ts | 238 ++++++++++++++++++++-- apps/api/src/bridge/bridge.module.ts | 47 ++++- 3 files changed, 275 insertions(+), 25 deletions(-) create mode 100644 apps/api/src/bridge/bridge.constants.ts diff --git a/apps/api/src/bridge/bridge.constants.ts b/apps/api/src/bridge/bridge.constants.ts new file mode 100644 index 0000000..63f0859 --- /dev/null +++ b/apps/api/src/bridge/bridge.constants.ts @@ -0,0 +1,15 @@ +/** + * Bridge Module Constants + * + * Injection tokens for the bridge module. + */ + +/** + * Injection token for the array of active IChatProvider instances. + * + * Use this token to inject all configured chat providers: + * ``` + * @Inject(CHAT_PROVIDERS) private readonly chatProviders: IChatProvider[] + * ``` + */ +export const CHAT_PROVIDERS = "CHAT_PROVIDERS"; diff --git a/apps/api/src/bridge/bridge.module.spec.ts b/apps/api/src/bridge/bridge.module.spec.ts index b43fc84..6660e7f 100644 --- a/apps/api/src/bridge/bridge.module.spec.ts +++ b/apps/api/src/bridge/bridge.module.spec.ts @@ -1,10 +1,13 @@ import { Test, TestingModule } from "@nestjs/testing"; import { BridgeModule } from "./bridge.module"; import { DiscordService } from "./discord/discord.service"; +import { MatrixService } from "./matrix/matrix.service"; import { StitcherService } from "../stitcher/stitcher.service"; import { PrismaService } from "../prisma/prisma.service"; import { BullMqService } from "../bullmq/bullmq.service"; -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { CHAT_PROVIDERS } from "./bridge.constants"; +import type { IChatProvider } from "./interfaces"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // Mock discord.js const mockReadyCallbacks: Array<() => void> = []; @@ -53,20 +56,93 @@ vi.mock("discord.js", () => { }; }); -describe("BridgeModule", () => { - let module: TestingModule; +// Mock matrix-bot-sdk +vi.mock("matrix-bot-sdk", () => { + return { + MatrixClient: class MockMatrixClient { + start = vi.fn().mockResolvedValue(undefined); + stop = vi.fn(); + on = vi.fn(); + sendMessage = vi.fn().mockResolvedValue("$mock-event-id"); + }, + SimpleFsStorageProvider: class MockStorage { + constructor(_path: string) { + // no-op + } + }, + AutojoinRoomsMixin: { + setupOnClient: vi.fn(), + }, + }; +}); - beforeEach(async () => { - // Set environment variables - process.env.DISCORD_BOT_TOKEN = "test-token"; - process.env.DISCORD_GUILD_ID = "test-guild-id"; - process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id"; +/** + * Saved environment variables to restore after each test + */ +interface SavedEnvVars { + DISCORD_BOT_TOKEN?: string; + DISCORD_GUILD_ID?: string; + DISCORD_CONTROL_CHANNEL_ID?: string; + MATRIX_ACCESS_TOKEN?: string; + MATRIX_HOMESERVER_URL?: string; + MATRIX_BOT_USER_ID?: string; + MATRIX_CONTROL_ROOM_ID?: string; + MATRIX_WORKSPACE_ID?: string; + ENCRYPTION_KEY?: string; +} + +describe("BridgeModule", () => { + let savedEnv: SavedEnvVars; + + beforeEach(() => { + // Save current env vars + savedEnv = { + DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN, + DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID, + DISCORD_CONTROL_CHANNEL_ID: process.env.DISCORD_CONTROL_CHANNEL_ID, + MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN, + MATRIX_HOMESERVER_URL: process.env.MATRIX_HOMESERVER_URL, + MATRIX_BOT_USER_ID: process.env.MATRIX_BOT_USER_ID, + MATRIX_CONTROL_ROOM_ID: process.env.MATRIX_CONTROL_ROOM_ID, + MATRIX_WORKSPACE_ID: process.env.MATRIX_WORKSPACE_ID, + ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, + }; + + // Clear all bridge env vars + delete process.env.DISCORD_BOT_TOKEN; + delete process.env.DISCORD_GUILD_ID; + delete process.env.DISCORD_CONTROL_CHANNEL_ID; + delete process.env.MATRIX_ACCESS_TOKEN; + delete process.env.MATRIX_HOMESERVER_URL; + delete process.env.MATRIX_BOT_USER_ID; + delete process.env.MATRIX_CONTROL_ROOM_ID; + delete process.env.MATRIX_WORKSPACE_ID; + + // Set encryption key (needed by StitcherService) process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; // Clear ready callbacks mockReadyCallbacks.length = 0; - module = await Test.createTestingModule({ + vi.clearAllMocks(); + }); + + afterEach(() => { + // Restore env vars + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + /** + * Helper to compile a test module with BridgeModule + */ + async function compileModule(): Promise { + return Test.createTestingModule({ imports: [BridgeModule], }) .overrideProvider(PrismaService) @@ -74,24 +150,144 @@ describe("BridgeModule", () => { .overrideProvider(BullMqService) .useValue({}) .compile(); + } - // Clear all mocks - vi.clearAllMocks(); + /** + * Helper to set Discord env vars + */ + function setDiscordEnv(): void { + process.env.DISCORD_BOT_TOKEN = "test-discord-token"; + process.env.DISCORD_GUILD_ID = "test-guild-id"; + process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id"; + } + + /** + * Helper to set Matrix env vars + */ + function setMatrixEnv(): void { + process.env.MATRIX_ACCESS_TOKEN = "test-matrix-token"; + process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com"; + process.env.MATRIX_BOT_USER_ID = "@bot:example.com"; + process.env.MATRIX_CONTROL_ROOM_ID = "!room:example.com"; + process.env.MATRIX_WORKSPACE_ID = "test-workspace-id"; + } + + describe("with both Discord and Matrix configured", () => { + let module: TestingModule; + + beforeEach(async () => { + setDiscordEnv(); + setMatrixEnv(); + module = await compileModule(); + }); + + it("should compile the module", () => { + expect(module).toBeDefined(); + }); + + it("should provide DiscordService", () => { + const discordService = module.get(DiscordService); + expect(discordService).toBeDefined(); + expect(discordService).toBeInstanceOf(DiscordService); + }); + + it("should provide MatrixService", () => { + const matrixService = module.get(MatrixService); + expect(matrixService).toBeDefined(); + expect(matrixService).toBeInstanceOf(MatrixService); + }); + + it("should provide CHAT_PROVIDERS with both providers", () => { + const chatProviders = module.get(CHAT_PROVIDERS); + expect(chatProviders).toBeDefined(); + expect(chatProviders).toHaveLength(2); + expect(chatProviders[0]).toBeInstanceOf(DiscordService); + expect(chatProviders[1]).toBeInstanceOf(MatrixService); + }); + + it("should provide StitcherService via StitcherModule", () => { + const stitcherService = module.get(StitcherService); + expect(stitcherService).toBeDefined(); + expect(stitcherService).toBeInstanceOf(StitcherService); + }); }); - it("should be defined", () => { - expect(module).toBeDefined(); + describe("with only Discord configured", () => { + let module: TestingModule; + + beforeEach(async () => { + setDiscordEnv(); + module = await compileModule(); + }); + + it("should compile the module", () => { + expect(module).toBeDefined(); + }); + + it("should provide DiscordService", () => { + const discordService = module.get(DiscordService); + expect(discordService).toBeDefined(); + expect(discordService).toBeInstanceOf(DiscordService); + }); + + it("should provide CHAT_PROVIDERS with only Discord", () => { + const chatProviders = module.get(CHAT_PROVIDERS); + expect(chatProviders).toBeDefined(); + expect(chatProviders).toHaveLength(1); + expect(chatProviders[0]).toBeInstanceOf(DiscordService); + }); }); - it("should provide DiscordService", () => { - const discordService = module.get(DiscordService); - expect(discordService).toBeDefined(); - expect(discordService).toBeInstanceOf(DiscordService); + describe("with only Matrix configured", () => { + let module: TestingModule; + + beforeEach(async () => { + setMatrixEnv(); + module = await compileModule(); + }); + + it("should compile the module", () => { + expect(module).toBeDefined(); + }); + + it("should provide MatrixService", () => { + const matrixService = module.get(MatrixService); + expect(matrixService).toBeDefined(); + expect(matrixService).toBeInstanceOf(MatrixService); + }); + + it("should provide CHAT_PROVIDERS with only Matrix", () => { + const chatProviders = module.get(CHAT_PROVIDERS); + expect(chatProviders).toBeDefined(); + expect(chatProviders).toHaveLength(1); + expect(chatProviders[0]).toBeInstanceOf(MatrixService); + }); }); - it("should provide StitcherService", () => { - const stitcherService = module.get(StitcherService); - expect(stitcherService).toBeDefined(); - expect(stitcherService).toBeInstanceOf(StitcherService); + describe("with neither bridge configured", () => { + let module: TestingModule; + + beforeEach(async () => { + // No env vars set for either bridge + module = await compileModule(); + }); + + it("should compile the module without errors", () => { + expect(module).toBeDefined(); + }); + + it("should provide CHAT_PROVIDERS as an empty array", () => { + const chatProviders = module.get(CHAT_PROVIDERS); + expect(chatProviders).toBeDefined(); + expect(chatProviders).toHaveLength(0); + expect(Array.isArray(chatProviders)).toBe(true); + }); + }); + + describe("CHAT_PROVIDERS token", () => { + it("should be a string constant", () => { + expect(CHAT_PROVIDERS).toBe("CHAT_PROVIDERS"); + expect(typeof CHAT_PROVIDERS).toBe("string"); + }); }); }); diff --git a/apps/api/src/bridge/bridge.module.ts b/apps/api/src/bridge/bridge.module.ts index af359c3..e7e5781 100644 --- a/apps/api/src/bridge/bridge.module.ts +++ b/apps/api/src/bridge/bridge.module.ts @@ -1,16 +1,55 @@ -import { Module } from "@nestjs/common"; +import { Logger, Module } from "@nestjs/common"; import { DiscordService } from "./discord/discord.service"; +import { MatrixService } from "./matrix/matrix.service"; import { StitcherModule } from "../stitcher/stitcher.module"; +import { CHAT_PROVIDERS } from "./bridge.constants"; +import type { IChatProvider } from "./interfaces"; + +const logger = new Logger("BridgeModule"); /** * Bridge Module - Chat platform integrations * - * Provides integration with chat platforms (Discord, Slack, Matrix, etc.) + * Provides integration with chat platforms (Discord, Matrix, etc.) * for controlling Mosaic Stack via chat commands. + * + * Both services are always registered as providers, but the CHAT_PROVIDERS + * injection token only includes bridges whose environment variables are set: + * - Discord: included when DISCORD_BOT_TOKEN is set + * - Matrix: included when MATRIX_ACCESS_TOKEN is set + * + * Both bridges can run simultaneously, and no error occurs if neither is configured. + * Consumers should inject CHAT_PROVIDERS for bridge-agnostic access to all active providers. */ @Module({ imports: [StitcherModule], - providers: [DiscordService], - exports: [DiscordService], + providers: [ + DiscordService, + MatrixService, + { + provide: CHAT_PROVIDERS, + useFactory: (discord: DiscordService, matrix: MatrixService): IChatProvider[] => { + const providers: IChatProvider[] = []; + + if (process.env.DISCORD_BOT_TOKEN) { + providers.push(discord); + logger.log("Discord bridge enabled (DISCORD_BOT_TOKEN detected)"); + } + + if (process.env.MATRIX_ACCESS_TOKEN) { + providers.push(matrix); + logger.log("Matrix bridge enabled (MATRIX_ACCESS_TOKEN detected)"); + } + + if (providers.length === 0) { + logger.warn("No chat bridges configured. Set DISCORD_BOT_TOKEN or MATRIX_ACCESS_TOKEN."); + } + + return providers; + }, + inject: [DiscordService, MatrixService], + }, + ], + exports: [DiscordService, MatrixService, CHAT_PROVIDERS], }) export class BridgeModule {}