feat(#378): Install matrix-bot-sdk and create MatrixService skeleton
- Add matrix-bot-sdk dependency to @mosaic/api - Create MatrixService implementing IChatProvider interface - Support connect/disconnect, message sending, thread management - Parse @mosaic and !mosaic command prefixes - Delegate commands to StitcherService (same flow as Discord) - Add comprehensive unit tests with mocked MatrixClient (31 tests) - Add Matrix env vars to .env.example Refs #378 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
16
.env.example
16
.env.example
@@ -316,6 +316,22 @@ RATE_LIMIT_STORAGE=redis
|
|||||||
# multi-tenant isolation. Each Discord bot instance should be configured for
|
# multi-tenant isolation. Each Discord bot instance should be configured for
|
||||||
# a single workspace.
|
# a single workspace.
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Matrix Bridge (Optional)
|
||||||
|
# ======================
|
||||||
|
# Matrix bot integration for chat-based control via Matrix protocol
|
||||||
|
# Requires a Matrix account with an access token for the bot user
|
||||||
|
# MATRIX_HOMESERVER_URL=https://matrix.example.com
|
||||||
|
# MATRIX_ACCESS_TOKEN=
|
||||||
|
# MATRIX_BOT_USER_ID=@mosaic-bot:example.com
|
||||||
|
# MATRIX_CONTROL_ROOM_ID=!roomid:example.com
|
||||||
|
# MATRIX_WORKSPACE_ID=your-workspace-uuid
|
||||||
|
#
|
||||||
|
# SECURITY: MATRIX_WORKSPACE_ID must be a valid workspace UUID from your database.
|
||||||
|
# All Matrix commands will execute within this workspace context for proper
|
||||||
|
# multi-tenant isolation. Each Matrix bot instance should be configured for
|
||||||
|
# a single workspace.
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# Orchestrator Configuration
|
# Orchestrator Configuration
|
||||||
# ======================
|
# ======================
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"marked-gfm-heading-id": "^4.1.3",
|
"marked-gfm-heading-id": "^4.1.3",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
|
"matrix-bot-sdk": "^0.8.0",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"openai": "^6.17.0",
|
"openai": "^6.17.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|||||||
1
apps/api/src/bridge/matrix/index.ts
Normal file
1
apps/api/src/bridge/matrix/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { MatrixService } from "./matrix.service";
|
||||||
658
apps/api/src/bridge/matrix/matrix.service.spec.ts
Normal file
658
apps/api/src/bridge/matrix/matrix.service.spec.ts
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { MatrixService } from "./matrix.service";
|
||||||
|
import { StitcherService } from "../../stitcher/stitcher.service";
|
||||||
|
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import type { ChatMessage } from "../interfaces";
|
||||||
|
|
||||||
|
// Mock matrix-bot-sdk
|
||||||
|
const mockMessageCallbacks: Array<(roomId: string, event: Record<string, unknown>) => void> = [];
|
||||||
|
const mockEventCallbacks: Array<(roomId: string, event: Record<string, unknown>) => void> = [];
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
start: vi.fn().mockResolvedValue(undefined),
|
||||||
|
stop: vi.fn(),
|
||||||
|
on: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(
|
||||||
|
(event: string, callback: (roomId: string, evt: Record<string, unknown>) => void) => {
|
||||||
|
if (event === "room.message") {
|
||||||
|
mockMessageCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
if (event === "room.event") {
|
||||||
|
mockEventCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
sendMessage: vi.fn().mockResolvedValue("$event-id-123"),
|
||||||
|
sendEvent: vi.fn().mockResolvedValue("$event-id-456"),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("matrix-bot-sdk", () => {
|
||||||
|
return {
|
||||||
|
MatrixClient: class MockMatrixClient {
|
||||||
|
start = mockClient.start;
|
||||||
|
stop = mockClient.stop;
|
||||||
|
on = mockClient.on;
|
||||||
|
sendMessage = mockClient.sendMessage;
|
||||||
|
sendEvent = mockClient.sendEvent;
|
||||||
|
},
|
||||||
|
SimpleFsStorageProvider: class MockStorageProvider {
|
||||||
|
constructor(_filename: string) {
|
||||||
|
// No-op for testing
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AutojoinRoomsMixin: {
|
||||||
|
setupOnClient: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MatrixService", () => {
|
||||||
|
let service: MatrixService;
|
||||||
|
let stitcherService: StitcherService;
|
||||||
|
|
||||||
|
const mockStitcherService = {
|
||||||
|
dispatchJob: vi.fn().mockResolvedValue({
|
||||||
|
jobId: "test-job-id",
|
||||||
|
queueName: "main",
|
||||||
|
status: "PENDING",
|
||||||
|
}),
|
||||||
|
trackJobEvent: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Set environment variables for testing
|
||||||
|
process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com";
|
||||||
|
process.env.MATRIX_ACCESS_TOKEN = "test-access-token";
|
||||||
|
process.env.MATRIX_BOT_USER_ID = "@mosaic-bot:example.com";
|
||||||
|
process.env.MATRIX_CONTROL_ROOM_ID = "!test-room:example.com";
|
||||||
|
process.env.MATRIX_WORKSPACE_ID = "test-workspace-id";
|
||||||
|
|
||||||
|
// Clear callbacks
|
||||||
|
mockMessageCallbacks.length = 0;
|
||||||
|
mockEventCallbacks.length = 0;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
MatrixService,
|
||||||
|
{
|
||||||
|
provide: StitcherService,
|
||||||
|
useValue: mockStitcherService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<MatrixService>(MatrixService);
|
||||||
|
stitcherService = module.get<StitcherService>(StitcherService);
|
||||||
|
|
||||||
|
// Clear all mocks
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Connection Management", () => {
|
||||||
|
it("should connect to Matrix", async () => {
|
||||||
|
await service.connect();
|
||||||
|
|
||||||
|
expect(mockClient.start).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disconnect from Matrix", async () => {
|
||||||
|
await service.connect();
|
||||||
|
await service.disconnect();
|
||||||
|
|
||||||
|
expect(mockClient.stop).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check connection status", async () => {
|
||||||
|
expect(service.isConnected()).toBe(false);
|
||||||
|
|
||||||
|
await service.connect();
|
||||||
|
expect(service.isConnected()).toBe(true);
|
||||||
|
|
||||||
|
await service.disconnect();
|
||||||
|
expect(service.isConnected()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Message Handling", () => {
|
||||||
|
it("should send a message to a room", async () => {
|
||||||
|
await service.connect();
|
||||||
|
await service.sendMessage("!test-room:example.com", "Hello, Matrix!");
|
||||||
|
|
||||||
|
expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "Hello, Matrix!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error if client is not connected", async () => {
|
||||||
|
await expect(service.sendMessage("!room:example.com", "Test")).rejects.toThrow(
|
||||||
|
"Matrix client is not connected"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Thread Management", () => {
|
||||||
|
it("should create a thread by sending an initial message", async () => {
|
||||||
|
await service.connect();
|
||||||
|
const threadId = await service.createThread({
|
||||||
|
channelId: "!test-room:example.com",
|
||||||
|
name: "Job #42",
|
||||||
|
message: "Starting job...",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(threadId).toBe("$event-id-123");
|
||||||
|
expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "[Job #42] Starting job...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send a message to a thread with m.thread relation", async () => {
|
||||||
|
await service.connect();
|
||||||
|
await service.sendThreadMessage({
|
||||||
|
threadId: "$root-event-id",
|
||||||
|
content: "Step completed",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "Step completed",
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: "m.thread",
|
||||||
|
event_id: "$root-event-id",
|
||||||
|
is_falling_back: true,
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: "$root-event-id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when creating thread without connection", async () => {
|
||||||
|
await expect(
|
||||||
|
service.createThread({
|
||||||
|
channelId: "!room:example.com",
|
||||||
|
name: "Test",
|
||||||
|
message: "Test",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Matrix client is not connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when sending thread message without connection", async () => {
|
||||||
|
await expect(
|
||||||
|
service.sendThreadMessage({
|
||||||
|
threadId: "$event-id",
|
||||||
|
content: "Test",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Matrix client is not connected");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Command Parsing", () => {
|
||||||
|
it("should parse @mosaic fix command", () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-1",
|
||||||
|
channelId: "!room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic fix 42",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = service.parseCommand(message);
|
||||||
|
|
||||||
|
expect(command).toEqual({
|
||||||
|
command: "fix",
|
||||||
|
args: ["42"],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse !mosaic fix command", () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-1",
|
||||||
|
channelId: "!room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "!mosaic fix 42",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = service.parseCommand(message);
|
||||||
|
|
||||||
|
expect(command).toEqual({
|
||||||
|
command: "fix",
|
||||||
|
args: ["42"],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse @mosaic status command", () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-2",
|
||||||
|
channelId: "!room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic status job-123",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = service.parseCommand(message);
|
||||||
|
|
||||||
|
expect(command).toEqual({
|
||||||
|
command: "status",
|
||||||
|
args: ["job-123"],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse @mosaic cancel command", () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-3",
|
||||||
|
channelId: "!room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic cancel job-456",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = service.parseCommand(message);
|
||||||
|
|
||||||
|
expect(command).toEqual({
|
||||||
|
command: "cancel",
|
||||||
|
args: ["job-456"],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse @mosaic verbose command", () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-4",
|
||||||
|
channelId: "!room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic verbose job-789",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = service.parseCommand(message);
|
||||||
|
|
||||||
|
expect(command).toEqual({
|
||||||
|
command: "verbose",
|
||||||
|
args: ["job-789"],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse @mosaic quiet command", () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-5",
|
||||||
|
channelId: "!room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic quiet",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = service.parseCommand(message);
|
||||||
|
|
||||||
|
expect(command).toEqual({
|
||||||
|
command: "quiet",
|
||||||
|
args: [],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse @mosaic help command", () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-6",
|
||||||
|
channelId: "!room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic help",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = service.parseCommand(message);
|
||||||
|
|
||||||
|
expect(command).toEqual({
|
||||||
|
command: "help",
|
||||||
|
args: [],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for non-command messages", () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-7",
|
||||||
|
channelId: "!room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "Just a regular message",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = service.parseCommand(message);
|
||||||
|
|
||||||
|
expect(command).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for messages without @mosaic or !mosaic mention", () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-8",
|
||||||
|
channelId: "!room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "fix 42",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = service.parseCommand(message);
|
||||||
|
|
||||||
|
expect(command).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle commands with multiple arguments", () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-9",
|
||||||
|
channelId: "!room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic fix 42 high-priority",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = service.parseCommand(message);
|
||||||
|
|
||||||
|
expect(command).toEqual({
|
||||||
|
command: "fix",
|
||||||
|
args: ["42", "high-priority"],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for invalid commands", () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-10",
|
||||||
|
channelId: "!room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic invalidcommand 42",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = service.parseCommand(message);
|
||||||
|
|
||||||
|
expect(command).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for @mosaic mention without a command", () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-11",
|
||||||
|
channelId: "!room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = service.parseCommand(message);
|
||||||
|
|
||||||
|
expect(command).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Command Execution", () => {
|
||||||
|
it("should forward fix command to stitcher", async () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-1",
|
||||||
|
channelId: "!test-room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic fix 42",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.connect();
|
||||||
|
await service.handleCommand({
|
||||||
|
command: "fix",
|
||||||
|
args: ["42"],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stitcherService.dispatchJob).toHaveBeenCalledWith({
|
||||||
|
workspaceId: "test-workspace-id",
|
||||||
|
type: "code-task",
|
||||||
|
priority: 10,
|
||||||
|
metadata: {
|
||||||
|
issueNumber: 42,
|
||||||
|
command: "fix",
|
||||||
|
channelId: "!test-room:example.com",
|
||||||
|
threadId: "$event-id-123",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should respond with help message", async () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-1",
|
||||||
|
channelId: "!test-room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic help",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.connect();
|
||||||
|
await service.handleCommand({
|
||||||
|
command: "help",
|
||||||
|
args: [],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.sendMessage).toHaveBeenCalledWith(
|
||||||
|
"!test-room:example.com",
|
||||||
|
expect.objectContaining({
|
||||||
|
body: expect.stringContaining("Available commands:"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send error for fix command without issue number", async () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-1",
|
||||||
|
channelId: "!test-room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic fix",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.connect();
|
||||||
|
await service.handleCommand({
|
||||||
|
command: "fix",
|
||||||
|
args: [],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.sendMessage).toHaveBeenCalledWith(
|
||||||
|
"!test-room:example.com",
|
||||||
|
expect.objectContaining({
|
||||||
|
body: expect.stringContaining("Usage:"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send error for fix command with non-numeric issue", async () => {
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-1",
|
||||||
|
channelId: "!test-room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic fix abc",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.connect();
|
||||||
|
await service.handleCommand({
|
||||||
|
command: "fix",
|
||||||
|
args: ["abc"],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.sendMessage).toHaveBeenCalledWith(
|
||||||
|
"!test-room:example.com",
|
||||||
|
expect.objectContaining({
|
||||||
|
body: expect.stringContaining("Invalid issue number"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Configuration", () => {
|
||||||
|
it("should throw error if MATRIX_HOMESERVER_URL is not set", async () => {
|
||||||
|
delete process.env.MATRIX_HOMESERVER_URL;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
MatrixService,
|
||||||
|
{
|
||||||
|
provide: StitcherService,
|
||||||
|
useValue: mockStitcherService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const newService = module.get<MatrixService>(MatrixService);
|
||||||
|
|
||||||
|
await expect(newService.connect()).rejects.toThrow("MATRIX_HOMESERVER_URL is required");
|
||||||
|
|
||||||
|
// Restore for other tests
|
||||||
|
process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error if MATRIX_ACCESS_TOKEN is not set", async () => {
|
||||||
|
delete process.env.MATRIX_ACCESS_TOKEN;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
MatrixService,
|
||||||
|
{
|
||||||
|
provide: StitcherService,
|
||||||
|
useValue: mockStitcherService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const newService = module.get<MatrixService>(MatrixService);
|
||||||
|
|
||||||
|
await expect(newService.connect()).rejects.toThrow("MATRIX_ACCESS_TOKEN is required");
|
||||||
|
|
||||||
|
// Restore for other tests
|
||||||
|
process.env.MATRIX_ACCESS_TOKEN = "test-access-token";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error if MATRIX_WORKSPACE_ID is not set", async () => {
|
||||||
|
delete process.env.MATRIX_WORKSPACE_ID;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
MatrixService,
|
||||||
|
{
|
||||||
|
provide: StitcherService,
|
||||||
|
useValue: mockStitcherService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const newService = module.get<MatrixService>(MatrixService);
|
||||||
|
|
||||||
|
await expect(newService.connect()).rejects.toThrow("MATRIX_WORKSPACE_ID is required");
|
||||||
|
|
||||||
|
// Restore for other tests
|
||||||
|
process.env.MATRIX_WORKSPACE_ID = "test-workspace-id";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use configured workspace ID from environment", async () => {
|
||||||
|
const testWorkspaceId = "configured-workspace-456";
|
||||||
|
process.env.MATRIX_WORKSPACE_ID = testWorkspaceId;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
MatrixService,
|
||||||
|
{
|
||||||
|
provide: StitcherService,
|
||||||
|
useValue: mockStitcherService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const newService = module.get<MatrixService>(MatrixService);
|
||||||
|
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: "msg-1",
|
||||||
|
channelId: "!test-room:example.com",
|
||||||
|
authorId: "@user:example.com",
|
||||||
|
authorName: "@user:example.com",
|
||||||
|
content: "@mosaic fix 42",
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await newService.connect();
|
||||||
|
await newService.handleCommand({
|
||||||
|
command: "fix",
|
||||||
|
args: ["42"],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockStitcherService.dispatchJob).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
workspaceId: testWorkspaceId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore for other tests
|
||||||
|
process.env.MATRIX_WORKSPACE_ID = "test-workspace-id";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error Logging Security", () => {
|
||||||
|
it("should sanitize sensitive data in error logs", async () => {
|
||||||
|
const loggerErrorSpy = vi.spyOn(
|
||||||
|
(service as Record<string, unknown>)["logger"] as { error: (...args: unknown[]) => void },
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.connect();
|
||||||
|
|
||||||
|
// Trigger room.event handler with null event to exercise error path
|
||||||
|
expect(mockEventCallbacks.length).toBeGreaterThan(0);
|
||||||
|
mockEventCallbacks[0]?.("!room:example.com", null as unknown as Record<string, unknown>);
|
||||||
|
|
||||||
|
// Verify error was logged
|
||||||
|
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Get the logged error
|
||||||
|
const loggedArgs = loggerErrorSpy.mock.calls[0];
|
||||||
|
const loggedError = loggedArgs?.[1] as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Verify non-sensitive error info is preserved
|
||||||
|
expect(loggedError).toBeDefined();
|
||||||
|
expect((loggedError as { message: string }).message).toBe("Received null event from Matrix");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not include access token in error output", () => {
|
||||||
|
// Verify the access token is stored privately and not exposed
|
||||||
|
const serviceAsRecord = service as unknown as Record<string, unknown>;
|
||||||
|
// The accessToken should exist but should not appear in any public-facing method output
|
||||||
|
expect(serviceAsRecord["accessToken"]).toBe("test-access-token");
|
||||||
|
|
||||||
|
// Verify isConnected does not leak token
|
||||||
|
const connected = service.isConnected();
|
||||||
|
expect(String(connected)).not.toContain("test-access-token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
468
apps/api/src/bridge/matrix/matrix.service.ts
Normal file
468
apps/api/src/bridge/matrix/matrix.service.ts
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin } from "matrix-bot-sdk";
|
||||||
|
import { StitcherService } from "../../stitcher/stitcher.service";
|
||||||
|
import { sanitizeForLogging } from "../../common/utils";
|
||||||
|
import type {
|
||||||
|
IChatProvider,
|
||||||
|
ChatMessage,
|
||||||
|
ChatCommand,
|
||||||
|
ThreadCreateOptions,
|
||||||
|
ThreadMessageOptions,
|
||||||
|
} from "../interfaces";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matrix room message event content
|
||||||
|
*/
|
||||||
|
interface MatrixMessageContent {
|
||||||
|
msgtype: string;
|
||||||
|
body: string;
|
||||||
|
"m.relates_to"?: MatrixRelatesTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matrix relationship metadata for threads (MSC3440)
|
||||||
|
*/
|
||||||
|
interface MatrixRelatesTo {
|
||||||
|
rel_type: string;
|
||||||
|
event_id: string;
|
||||||
|
is_falling_back?: boolean;
|
||||||
|
"m.in_reply_to"?: {
|
||||||
|
event_id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matrix room event structure
|
||||||
|
*/
|
||||||
|
interface MatrixRoomEvent {
|
||||||
|
event_id: string;
|
||||||
|
sender: string;
|
||||||
|
origin_server_ts: number;
|
||||||
|
content: MatrixMessageContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matrix Service - Matrix chat platform integration
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Connect to Matrix via access token
|
||||||
|
* - Listen for commands in designated rooms
|
||||||
|
* - Forward commands to stitcher
|
||||||
|
* - Receive status updates from herald
|
||||||
|
* - Post updates to threads (MSC3440)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MatrixService implements IChatProvider {
|
||||||
|
private readonly logger = new Logger(MatrixService.name);
|
||||||
|
private client: MatrixClient | null = null;
|
||||||
|
private connected = false;
|
||||||
|
private readonly homeserverUrl: string;
|
||||||
|
private readonly accessToken: string;
|
||||||
|
private readonly botUserId: string;
|
||||||
|
private readonly controlRoomId: string;
|
||||||
|
private readonly workspaceId: string;
|
||||||
|
|
||||||
|
constructor(private readonly stitcherService: StitcherService) {
|
||||||
|
this.homeserverUrl = process.env.MATRIX_HOMESERVER_URL ?? "";
|
||||||
|
this.accessToken = process.env.MATRIX_ACCESS_TOKEN ?? "";
|
||||||
|
this.botUserId = process.env.MATRIX_BOT_USER_ID ?? "";
|
||||||
|
this.controlRoomId = process.env.MATRIX_CONTROL_ROOM_ID ?? "";
|
||||||
|
this.workspaceId = process.env.MATRIX_WORKSPACE_ID ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to Matrix homeserver
|
||||||
|
*/
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (!this.homeserverUrl) {
|
||||||
|
throw new Error("MATRIX_HOMESERVER_URL is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.accessToken) {
|
||||||
|
throw new Error("MATRIX_ACCESS_TOKEN is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.workspaceId) {
|
||||||
|
throw new Error("MATRIX_WORKSPACE_ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log("Connecting to Matrix...");
|
||||||
|
|
||||||
|
const storage = new SimpleFsStorageProvider("matrix-bot-storage.json");
|
||||||
|
this.client = new MatrixClient(this.homeserverUrl, this.accessToken, storage);
|
||||||
|
|
||||||
|
// Auto-join rooms when invited
|
||||||
|
AutojoinRoomsMixin.setupOnClient(this.client);
|
||||||
|
|
||||||
|
// Setup event handlers
|
||||||
|
this.setupEventHandlers();
|
||||||
|
|
||||||
|
// Start syncing
|
||||||
|
await this.client.start();
|
||||||
|
this.connected = true;
|
||||||
|
this.logger.log(`Matrix bot connected as ${this.botUserId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event handlers for Matrix client
|
||||||
|
*/
|
||||||
|
private setupEventHandlers(): void {
|
||||||
|
if (!this.client) return;
|
||||||
|
|
||||||
|
this.client.on("room.message", (roomId: string, event: MatrixRoomEvent) => {
|
||||||
|
// Ignore messages from the bot itself
|
||||||
|
if (event.sender === this.botUserId) return;
|
||||||
|
|
||||||
|
// Check if message is in control room
|
||||||
|
if (roomId !== this.controlRoomId) return;
|
||||||
|
|
||||||
|
// Only handle text messages
|
||||||
|
if (event.content.msgtype !== "m.text") return;
|
||||||
|
|
||||||
|
// Parse message into ChatMessage format
|
||||||
|
const chatMessage: ChatMessage = {
|
||||||
|
id: event.event_id,
|
||||||
|
channelId: roomId,
|
||||||
|
authorId: event.sender,
|
||||||
|
authorName: event.sender,
|
||||||
|
content: event.content.body,
|
||||||
|
timestamp: new Date(event.origin_server_ts),
|
||||||
|
...(event.content["m.relates_to"]?.rel_type === "m.thread" && {
|
||||||
|
threadId: event.content["m.relates_to"].event_id,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse command
|
||||||
|
const command = this.parseCommand(chatMessage);
|
||||||
|
if (command) {
|
||||||
|
void this.handleCommand(command);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on("room.event", (_roomId: string, event: MatrixRoomEvent | null) => {
|
||||||
|
// Handle errors emitted as events
|
||||||
|
if (!event) {
|
||||||
|
const error = new Error("Received null event from Matrix");
|
||||||
|
const sanitizedError = sanitizeForLogging(error);
|
||||||
|
this.logger.error("Matrix client error:", sanitizedError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from Matrix
|
||||||
|
*/
|
||||||
|
disconnect(): Promise<void> {
|
||||||
|
this.logger.log("Disconnecting from Matrix...");
|
||||||
|
this.connected = false;
|
||||||
|
if (this.client) {
|
||||||
|
this.client.stop();
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the provider is connected
|
||||||
|
*/
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a room
|
||||||
|
*/
|
||||||
|
async sendMessage(roomId: string, content: string): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error("Matrix client is not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageContent: MatrixMessageContent = {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: content,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.client.sendMessage(roomId, messageContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a thread for job updates (MSC3440)
|
||||||
|
*
|
||||||
|
* Matrix threads are created by sending an initial message
|
||||||
|
* and then replying with m.thread relation. The initial
|
||||||
|
* message event ID becomes the thread root.
|
||||||
|
*/
|
||||||
|
async createThread(options: ThreadCreateOptions): Promise<string> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error("Matrix client is not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { channelId, name, message } = options;
|
||||||
|
|
||||||
|
// Send the initial message that becomes the thread root
|
||||||
|
const initialContent: MatrixMessageContent = {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: `[${name}] ${message}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventId = await this.client.sendMessage(channelId, initialContent);
|
||||||
|
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a thread (MSC3440)
|
||||||
|
*
|
||||||
|
* Uses m.thread relation to associate the message with the thread root event.
|
||||||
|
*/
|
||||||
|
async sendThreadMessage(options: ThreadMessageOptions): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error("Matrix client is not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { threadId, content } = options;
|
||||||
|
|
||||||
|
// Extract roomId from the control room (threads are room-scoped)
|
||||||
|
const roomId = this.controlRoomId;
|
||||||
|
|
||||||
|
const threadContent: MatrixMessageContent = {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: content,
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: "m.thread",
|
||||||
|
event_id: threadId,
|
||||||
|
is_falling_back: true,
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: threadId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.client.sendMessage(roomId, threadContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a command from a message
|
||||||
|
*/
|
||||||
|
parseCommand(message: ChatMessage): ChatCommand | null {
|
||||||
|
const { content } = message;
|
||||||
|
|
||||||
|
// Check if message mentions @mosaic or uses !mosaic prefix
|
||||||
|
const lowerContent = content.toLowerCase();
|
||||||
|
if (!lowerContent.includes("@mosaic") && !lowerContent.includes("!mosaic")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract command and arguments
|
||||||
|
const parts = content.trim().split(/\s+/);
|
||||||
|
const mosaicIndex = parts.findIndex(
|
||||||
|
(part) => part.toLowerCase().includes("@mosaic") || part.toLowerCase().includes("!mosaic")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mosaicIndex === -1 || mosaicIndex === parts.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandPart = parts[mosaicIndex + 1];
|
||||||
|
if (!commandPart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = commandPart.toLowerCase();
|
||||||
|
const args = parts.slice(mosaicIndex + 2);
|
||||||
|
|
||||||
|
// Valid commands
|
||||||
|
const validCommands = ["fix", "status", "cancel", "verbose", "quiet", "help"];
|
||||||
|
|
||||||
|
if (!validCommands.includes(command)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a parsed command
|
||||||
|
*/
|
||||||
|
async handleCommand(command: ChatCommand): Promise<void> {
|
||||||
|
const { command: cmd, args, message } = command;
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Handling command: ${cmd} with args: ${args.join(", ")} from ${message.authorName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
case "fix":
|
||||||
|
await this.handleFixCommand(args, message);
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
await this.handleStatusCommand(args, message);
|
||||||
|
break;
|
||||||
|
case "cancel":
|
||||||
|
await this.handleCancelCommand(args, message);
|
||||||
|
break;
|
||||||
|
case "verbose":
|
||||||
|
await this.handleVerboseCommand(args, message);
|
||||||
|
break;
|
||||||
|
case "quiet":
|
||||||
|
await this.handleQuietCommand(args, message);
|
||||||
|
break;
|
||||||
|
case "help":
|
||||||
|
await this.handleHelpCommand(args, message);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await this.sendMessage(
|
||||||
|
message.channelId,
|
||||||
|
`Unknown command: ${cmd}. Type \`@mosaic help\` or \`!mosaic help\` for available commands.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle fix command - Start a job for an issue
|
||||||
|
*/
|
||||||
|
private async handleFixCommand(args: string[], message: ChatMessage): Promise<void> {
|
||||||
|
if (args.length === 0 || !args[0]) {
|
||||||
|
await this.sendMessage(
|
||||||
|
message.channelId,
|
||||||
|
"Usage: `@mosaic fix <issue-number>` or `!mosaic fix <issue-number>`"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueNumber = parseInt(args[0], 10);
|
||||||
|
|
||||||
|
if (isNaN(issueNumber)) {
|
||||||
|
await this.sendMessage(
|
||||||
|
message.channelId,
|
||||||
|
"Invalid issue number. Please provide a numeric issue number."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create thread for job updates
|
||||||
|
const threadId = await this.createThread({
|
||||||
|
channelId: message.channelId,
|
||||||
|
name: `Job #${String(issueNumber)}`,
|
||||||
|
message: `Starting job for issue #${String(issueNumber)}...`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch job to stitcher
|
||||||
|
const result = await this.stitcherService.dispatchJob({
|
||||||
|
workspaceId: this.workspaceId,
|
||||||
|
type: "code-task",
|
||||||
|
priority: 10,
|
||||||
|
metadata: {
|
||||||
|
issueNumber,
|
||||||
|
command: "fix",
|
||||||
|
channelId: message.channelId,
|
||||||
|
threadId: threadId,
|
||||||
|
authorId: message.authorId,
|
||||||
|
authorName: message.authorName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send confirmation to thread
|
||||||
|
await this.sendThreadMessage({
|
||||||
|
threadId,
|
||||||
|
content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle status command - Get job status
|
||||||
|
*/
|
||||||
|
private async handleStatusCommand(args: string[], message: ChatMessage): Promise<void> {
|
||||||
|
if (args.length === 0 || !args[0]) {
|
||||||
|
await this.sendMessage(
|
||||||
|
message.channelId,
|
||||||
|
"Usage: `@mosaic status <job-id>` or `!mosaic status <job-id>`"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = args[0];
|
||||||
|
|
||||||
|
// TODO: Implement job status retrieval from stitcher
|
||||||
|
await this.sendMessage(
|
||||||
|
message.channelId,
|
||||||
|
`Status command not yet implemented for job: ${jobId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle cancel command - Cancel a running job
|
||||||
|
*/
|
||||||
|
private async handleCancelCommand(args: string[], message: ChatMessage): Promise<void> {
|
||||||
|
if (args.length === 0 || !args[0]) {
|
||||||
|
await this.sendMessage(
|
||||||
|
message.channelId,
|
||||||
|
"Usage: `@mosaic cancel <job-id>` or `!mosaic cancel <job-id>`"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = args[0];
|
||||||
|
|
||||||
|
// TODO: Implement job cancellation in stitcher
|
||||||
|
await this.sendMessage(
|
||||||
|
message.channelId,
|
||||||
|
`Cancel command not yet implemented for job: ${jobId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle verbose command - Stream full logs to thread
|
||||||
|
*/
|
||||||
|
private async handleVerboseCommand(args: string[], message: ChatMessage): Promise<void> {
|
||||||
|
if (args.length === 0 || !args[0]) {
|
||||||
|
await this.sendMessage(
|
||||||
|
message.channelId,
|
||||||
|
"Usage: `@mosaic verbose <job-id>` or `!mosaic verbose <job-id>`"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = args[0];
|
||||||
|
|
||||||
|
// TODO: Implement verbose logging
|
||||||
|
await this.sendMessage(message.channelId, `Verbose mode not yet implemented for job: ${jobId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle quiet command - Reduce notifications
|
||||||
|
*/
|
||||||
|
private async handleQuietCommand(_args: string[], message: ChatMessage): Promise<void> {
|
||||||
|
// TODO: Implement quiet mode
|
||||||
|
await this.sendMessage(
|
||||||
|
message.channelId,
|
||||||
|
"Quiet mode not yet implemented. Currently showing milestone updates only."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle help command - Show available commands
|
||||||
|
*/
|
||||||
|
private async handleHelpCommand(_args: string[], message: ChatMessage): Promise<void> {
|
||||||
|
const helpMessage = `
|
||||||
|
**Available commands:**
|
||||||
|
|
||||||
|
\`@mosaic fix <issue>\` or \`!mosaic fix <issue>\` - Start job for issue
|
||||||
|
\`@mosaic status <job>\` or \`!mosaic status <job>\` - Get job status
|
||||||
|
\`@mosaic cancel <job>\` or \`!mosaic cancel <job>\` - Cancel running job
|
||||||
|
\`@mosaic verbose <job>\` or \`!mosaic verbose <job>\` - Stream full logs to thread
|
||||||
|
\`@mosaic quiet\` or \`!mosaic quiet\` - Reduce notifications
|
||||||
|
\`@mosaic help\` or \`!mosaic help\` - Show this help message
|
||||||
|
|
||||||
|
**Noise Management:**
|
||||||
|
- Main room: Low verbosity (milestones only)
|
||||||
|
- Job threads: Medium verbosity (step completions)
|
||||||
|
- DMs: Configurable per user
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
await this.sendMessage(message.channelId, helpMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
728
pnpm-lock.yaml
generated
728
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user