fix(#377): remediate code review and security findings
- 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>
This commit is contained in:
@@ -46,6 +46,16 @@ const logger = new Logger("BridgeModule");
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.MATRIX_ACCESS_TOKEN) {
|
if (process.env.MATRIX_ACCESS_TOKEN) {
|
||||||
|
const missingVars = [
|
||||||
|
"MATRIX_HOMESERVER_URL",
|
||||||
|
"MATRIX_BOT_USER_ID",
|
||||||
|
"MATRIX_WORKSPACE_ID",
|
||||||
|
].filter((v) => !process.env[v]);
|
||||||
|
if (missingVars.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
`Matrix bridge enabled but missing: ${missingVars.join(", ")}. connect() will fail.`
|
||||||
|
);
|
||||||
|
}
|
||||||
providers.push(matrix);
|
providers.push(matrix);
|
||||||
logger.log("Matrix bridge enabled (MATRIX_ACCESS_TOKEN detected)");
|
logger.log("Matrix bridge enabled (MATRIX_ACCESS_TOKEN detected)");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ describe("DiscordService", () => {
|
|||||||
await service.connect();
|
await service.connect();
|
||||||
await service.sendThreadMessage({
|
await service.sendThreadMessage({
|
||||||
threadId: "thread-123",
|
threadId: "thread-123",
|
||||||
|
channelId: "test-channel-id",
|
||||||
content: "Step completed",
|
content: "Step completed",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ export class DiscordService implements IChatProvider {
|
|||||||
// Send confirmation to thread
|
// Send confirmation to thread
|
||||||
await this.sendThreadMessage({
|
await this.sendThreadMessage({
|
||||||
threadId,
|
threadId,
|
||||||
|
channelId: message.channelId,
|
||||||
content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`,
|
content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface ThreadCreateOptions {
|
|||||||
|
|
||||||
export interface ThreadMessageOptions {
|
export interface ThreadMessageOptions {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
channelId: string;
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -486,9 +486,9 @@ describe("Matrix Bridge Integration Tests", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Confirmation message sent as thread reply
|
// Confirmation message sent as thread reply (uses channelId from message, not hardcoded controlRoomId)
|
||||||
const confirmationCall = sendCalls[1];
|
const confirmationCall = sendCalls[1];
|
||||||
expect(confirmationCall?.[0]).toBe("!control-room:example.com");
|
expect(confirmationCall?.[0]).toBe("!room:example.com");
|
||||||
expect(confirmationCall?.[1]).toEqual(
|
expect(confirmationCall?.[1]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
body: expect.stringContaining("Job created: job-integ-001"),
|
body: expect.stringContaining("Job created: job-integ-001"),
|
||||||
@@ -519,7 +519,7 @@ describe("Matrix Bridge Integration Tests", () => {
|
|||||||
setMatrixEnv();
|
setMatrixEnv();
|
||||||
|
|
||||||
// Create a connected mock MatrixService that tracks sendThreadMessage calls
|
// Create a connected mock MatrixService that tracks sendThreadMessage calls
|
||||||
const threadMessages: Array<{ threadId: string; content: string }> = [];
|
const threadMessages: Array<{ threadId: string; channelId: string; content: string }> = [];
|
||||||
const mockMatrixProvider: IChatProvider = {
|
const mockMatrixProvider: IChatProvider = {
|
||||||
connect: vi.fn().mockResolvedValue(undefined),
|
connect: vi.fn().mockResolvedValue(undefined),
|
||||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -527,7 +527,7 @@ describe("Matrix Bridge Integration Tests", () => {
|
|||||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
createThread: vi.fn().mockResolvedValue("$thread-id"),
|
createThread: vi.fn().mockResolvedValue("$thread-id"),
|
||||||
sendThreadMessage: vi.fn().mockImplementation(async (options) => {
|
sendThreadMessage: vi.fn().mockImplementation(async (options) => {
|
||||||
threadMessages.push(options as { threadId: string; content: string });
|
threadMessages.push(options as { threadId: string; channelId: string; content: string });
|
||||||
}),
|
}),
|
||||||
parseCommand: vi.fn().mockReturnValue(null),
|
parseCommand: vi.fn().mockReturnValue(null),
|
||||||
};
|
};
|
||||||
@@ -545,6 +545,7 @@ describe("Matrix Bridge Integration Tests", () => {
|
|||||||
payload: {
|
payload: {
|
||||||
metadata: {
|
metadata: {
|
||||||
threadId: "$thread-herald-root",
|
threadId: "$thread-herald-root",
|
||||||
|
channelId: "!herald-room:example.com",
|
||||||
issueNumber: 55,
|
issueNumber: 55,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -617,6 +618,7 @@ describe("Matrix Bridge Integration Tests", () => {
|
|||||||
payload: {
|
payload: {
|
||||||
metadata: {
|
metadata: {
|
||||||
threadId: "$thread-skip",
|
threadId: "$thread-skip",
|
||||||
|
channelId: "!skip-room:example.com",
|
||||||
issueNumber: 1,
|
issueNumber: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -689,6 +691,7 @@ describe("Matrix Bridge Integration Tests", () => {
|
|||||||
payload: {
|
payload: {
|
||||||
metadata: {
|
metadata: {
|
||||||
threadId: "$thread-err",
|
threadId: "$thread-err",
|
||||||
|
channelId: "!err-room:example.com",
|
||||||
issueNumber: 77,
|
issueNumber: 77,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ describe("MatrixRoomService", () => {
|
|||||||
|
|
||||||
const mockCreateRoom = vi.fn().mockResolvedValue("!new-room:example.com");
|
const mockCreateRoom = vi.fn().mockResolvedValue("!new-room:example.com");
|
||||||
|
|
||||||
|
const mockMatrixClient = {
|
||||||
|
createRoom: mockCreateRoom,
|
||||||
|
};
|
||||||
|
|
||||||
const mockMatrixService = {
|
const mockMatrixService = {
|
||||||
isConnected: vi.fn().mockReturnValue(true),
|
isConnected: vi.fn().mockReturnValue(true),
|
||||||
// Private field accessed by MatrixRoomService.getMatrixClient()
|
getClient: vi.fn().mockReturnValue(mockMatrixClient),
|
||||||
client: {
|
|
||||||
createRoom: mockCreateRoom,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockPrismaService = {
|
const mockPrismaService = {
|
||||||
|
|||||||
@@ -64,10 +64,17 @@ export class MatrixRoomService {
|
|||||||
const roomId = await client.createRoom(roomOptions);
|
const roomId = await client.createRoom(roomOptions);
|
||||||
|
|
||||||
// Store the room mapping
|
// Store the room mapping
|
||||||
await this.prisma.workspace.update({
|
try {
|
||||||
where: { id: workspaceId },
|
await this.prisma.workspace.update({
|
||||||
data: { matrixRoomId: roomId },
|
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}`);
|
this.logger.log(`Matrix room ${roomId} provisioned and linked to workspace ${workspaceId}`);
|
||||||
|
|
||||||
@@ -134,19 +141,11 @@ export class MatrixRoomService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Access the underlying MatrixClient from the MatrixService.
|
* Access the underlying MatrixClient from the MatrixService
|
||||||
*
|
* via the public getClient() accessor.
|
||||||
* The MatrixService stores the client as a private field, so we
|
|
||||||
* access it via a known private property name. This is intentional
|
|
||||||
* to avoid exposing the client publicly on the service interface.
|
|
||||||
*/
|
*/
|
||||||
private getMatrixClient(): MatrixClient | null {
|
private getMatrixClient(): MatrixClient | null {
|
||||||
if (!this.matrixService) return null;
|
if (!this.matrixService) return null;
|
||||||
|
return this.matrixService.getClient();
|
||||||
// Access the private client field from MatrixService.
|
|
||||||
// MatrixService stores `client` as a private property; we use a type assertion
|
|
||||||
// to access it since exposing it publicly is not appropriate for the service API.
|
|
||||||
const service = this.matrixService as unknown as { client: MatrixClient | null };
|
|
||||||
return service.client;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,14 +195,26 @@ export class MatrixStreamingService {
|
|||||||
this.logger.error(`Stream error in room ${roomId}: ${errorMessage}`);
|
this.logger.error(`Stream error in room ${roomId}: ${errorMessage}`);
|
||||||
|
|
||||||
// Edit message to show error
|
// Edit message to show error
|
||||||
const errorContent = accumulatedText
|
try {
|
||||||
? `${accumulatedText}\n\n[Streaming error: ${errorMessage}]`
|
const errorContent = accumulatedText
|
||||||
: `[Streaming error: ${errorMessage}]`;
|
? `${accumulatedText}\n\n[Streaming error: ${errorMessage}]`
|
||||||
|
: `[Streaming error: ${errorMessage}]`;
|
||||||
|
|
||||||
await this.editMessage(roomId, eventId, errorContent);
|
await this.editMessage(roomId, eventId, errorContent);
|
||||||
|
} catch (editError: unknown) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to edit error message in ${roomId}: ${editError instanceof Error ? editError.message : "unknown"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Step 4: Clear typing indicator
|
// Step 4: Clear typing indicator
|
||||||
await this.setTypingIndicator(roomId, false);
|
try {
|
||||||
|
await this.setTypingIndicator(roomId, false);
|
||||||
|
} catch (typingError: unknown) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to clear typing indicator in ${roomId}: ${typingError instanceof Error ? typingError.message : "unknown"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Final edit with clean output (if no error)
|
// Step 5: Final edit with clean output (if no error)
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ describe("MatrixService", () => {
|
|||||||
await service.connect();
|
await service.connect();
|
||||||
await service.sendThreadMessage({
|
await service.sendThreadMessage({
|
||||||
threadId: "$root-event-id",
|
threadId: "$root-event-id",
|
||||||
|
channelId: "!test-room:example.com",
|
||||||
content: "Step completed",
|
content: "Step completed",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,6 +189,28 @@ describe("MatrixService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should fall back to controlRoomId when channelId is empty", async () => {
|
||||||
|
await service.connect();
|
||||||
|
await service.sendThreadMessage({
|
||||||
|
threadId: "$root-event-id",
|
||||||
|
channelId: "",
|
||||||
|
content: "Fallback message",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", {
|
||||||
|
msgtype: "m.text",
|
||||||
|
body: "Fallback message",
|
||||||
|
"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 () => {
|
it("should throw error when creating thread without connection", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
service.createThread({
|
service.createThread({
|
||||||
@@ -202,6 +225,7 @@ describe("MatrixService", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
service.sendThreadMessage({
|
service.sendThreadMessage({
|
||||||
threadId: "$event-id",
|
threadId: "$event-id",
|
||||||
|
channelId: "!room:example.com",
|
||||||
content: "Test",
|
content: "Test",
|
||||||
})
|
})
|
||||||
).rejects.toThrow("Matrix client is not connected");
|
).rejects.toThrow("Matrix client is not connected");
|
||||||
@@ -764,6 +788,32 @@ describe("MatrixService", () => {
|
|||||||
process.env.MATRIX_ACCESS_TOKEN = "test-access-token";
|
process.env.MATRIX_ACCESS_TOKEN = "test-access-token";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should throw error if MATRIX_BOT_USER_ID is not set", async () => {
|
||||||
|
delete process.env.MATRIX_BOT_USER_ID;
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
MatrixService,
|
||||||
|
CommandParserService,
|
||||||
|
{
|
||||||
|
provide: StitcherService,
|
||||||
|
useValue: mockStitcherService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MatrixRoomService,
|
||||||
|
useValue: mockMatrixRoomService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const newService = module.get<MatrixService>(MatrixService);
|
||||||
|
|
||||||
|
await expect(newService.connect()).rejects.toThrow("MATRIX_BOT_USER_ID is required");
|
||||||
|
|
||||||
|
// Restore for other tests
|
||||||
|
process.env.MATRIX_BOT_USER_ID = "@mosaic-bot:example.com";
|
||||||
|
});
|
||||||
|
|
||||||
it("should throw error if MATRIX_WORKSPACE_ID is not set", async () => {
|
it("should throw error if MATRIX_WORKSPACE_ID is not set", async () => {
|
||||||
delete process.env.MATRIX_WORKSPACE_ID;
|
delete process.env.MATRIX_WORKSPACE_ID;
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ export class MatrixService implements IChatProvider {
|
|||||||
throw new Error("MATRIX_WORKSPACE_ID is required");
|
throw new Error("MATRIX_WORKSPACE_ID is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.botUserId) {
|
||||||
|
throw new Error("MATRIX_BOT_USER_ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log("Connecting to Matrix...");
|
this.logger.log("Connecting to Matrix...");
|
||||||
|
|
||||||
const storage = new SimpleFsStorageProvider("matrix-bot-storage.json");
|
const storage = new SimpleFsStorageProvider("matrix-bot-storage.json");
|
||||||
@@ -129,7 +133,12 @@ export class MatrixService implements IChatProvider {
|
|||||||
// Only handle text messages
|
// Only handle text messages
|
||||||
if (event.content.msgtype !== "m.text") return;
|
if (event.content.msgtype !== "m.text") return;
|
||||||
|
|
||||||
void this.handleRoomMessage(roomId, event);
|
this.handleRoomMessage(roomId, event).catch((error: unknown) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Error handling room message in ${roomId}:`,
|
||||||
|
error instanceof Error ? error.message : error
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.client.on("room.event", (_roomId: string, event: MatrixRoomEvent | null) => {
|
this.client.on("room.event", (_roomId: string, event: MatrixRoomEvent | null) => {
|
||||||
@@ -332,10 +341,10 @@ export class MatrixService implements IChatProvider {
|
|||||||
throw new Error("Matrix client is not connected");
|
throw new Error("Matrix client is not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { threadId, content } = options;
|
const { threadId, channelId, content } = options;
|
||||||
|
|
||||||
// Extract roomId from the control room (threads are room-scoped)
|
// Use the channelId from options (threads are room-scoped), fall back to control room
|
||||||
const roomId = this.controlRoomId;
|
const roomId = channelId || this.controlRoomId;
|
||||||
|
|
||||||
const threadContent: MatrixMessageContent = {
|
const threadContent: MatrixMessageContent = {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
@@ -488,25 +497,38 @@ export class MatrixService implements IChatProvider {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Dispatch job to stitcher
|
// Dispatch job to stitcher
|
||||||
const result = await this.stitcherService.dispatchJob({
|
try {
|
||||||
workspaceId: targetWorkspaceId,
|
const result = await this.stitcherService.dispatchJob({
|
||||||
type: "code-task",
|
workspaceId: targetWorkspaceId,
|
||||||
priority: 10,
|
type: "code-task",
|
||||||
metadata: {
|
priority: 10,
|
||||||
issueNumber,
|
metadata: {
|
||||||
command: "fix",
|
issueNumber,
|
||||||
channelId: message.channelId,
|
command: "fix",
|
||||||
threadId: threadId,
|
channelId: message.channelId,
|
||||||
authorId: message.authorId,
|
threadId: threadId,
|
||||||
authorName: message.authorName,
|
authorId: message.authorId,
|
||||||
},
|
authorName: message.authorName,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Send confirmation to thread
|
// Send confirmation to thread
|
||||||
await this.sendThreadMessage({
|
await this.sendThreadMessage({
|
||||||
threadId,
|
threadId,
|
||||||
content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`,
|
channelId: message.channelId,
|
||||||
});
|
content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to dispatch job for issue #${String(issueNumber)}: ${errorMessage}`
|
||||||
|
);
|
||||||
|
await this.sendThreadMessage({
|
||||||
|
threadId,
|
||||||
|
channelId: message.channelId,
|
||||||
|
content: `Failed to start job: ${errorMessage}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ describe("HeraldService", () => {
|
|||||||
|
|
||||||
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
mockPrisma.jobEvent.findFirst.mockResolvedValue({
|
||||||
payload: {
|
payload: {
|
||||||
metadata: { issueNumber: 42, threadId: "thread-123" },
|
metadata: { issueNumber: 42, threadId: "thread-123", channelId: "channel-abc" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,10 +126,12 @@ describe("HeraldService", () => {
|
|||||||
// Assert
|
// Assert
|
||||||
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
|
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
|
||||||
threadId: "thread-123",
|
threadId: "thread-123",
|
||||||
|
channelId: "channel-abc",
|
||||||
content: expect.stringContaining("Job created"),
|
content: expect.stringContaining("Job created"),
|
||||||
});
|
});
|
||||||
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({
|
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({
|
||||||
threadId: "thread-123",
|
threadId: "thread-123",
|
||||||
|
channelId: "channel-abc",
|
||||||
content: expect.stringContaining("Job created"),
|
content: expect.stringContaining("Job created"),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -152,10 +154,12 @@ describe("HeraldService", () => {
|
|||||||
// Assert
|
// Assert
|
||||||
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
|
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
|
||||||
threadId: "thread-123",
|
threadId: "thread-123",
|
||||||
|
channelId: "channel-abc",
|
||||||
content: expect.stringContaining("Job started"),
|
content: expect.stringContaining("Job started"),
|
||||||
});
|
});
|
||||||
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({
|
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({
|
||||||
threadId: "thread-123",
|
threadId: "thread-123",
|
||||||
|
channelId: "channel-abc",
|
||||||
content: expect.stringContaining("Job started"),
|
content: expect.stringContaining("Job started"),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -178,6 +182,7 @@ describe("HeraldService", () => {
|
|||||||
// Assert
|
// Assert
|
||||||
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
|
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
|
||||||
threadId: "thread-123",
|
threadId: "thread-123",
|
||||||
|
channelId: "channel-abc",
|
||||||
content: expect.stringContaining("completed"),
|
content: expect.stringContaining("completed"),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -200,11 +205,13 @@ describe("HeraldService", () => {
|
|||||||
// Assert
|
// Assert
|
||||||
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
|
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
|
||||||
threadId: "thread-123",
|
threadId: "thread-123",
|
||||||
|
channelId: "channel-abc",
|
||||||
content: expect.stringContaining("encountered an issue"),
|
content: expect.stringContaining("encountered an issue"),
|
||||||
});
|
});
|
||||||
// Verify the actual message doesn't contain demanding language
|
// Verify the actual message doesn't contain demanding language
|
||||||
const actualCall = mockProviderA.sendThreadMessage.mock.calls[0][0] as {
|
const actualCall = mockProviderA.sendThreadMessage.mock.calls[0][0] as {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
channelId: string;
|
||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
expect(actualCall.content).not.toMatch(/FAILED|ERROR|CRITICAL|URGENT/);
|
expect(actualCall.content).not.toMatch(/FAILED|ERROR|CRITICAL|URGENT/);
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export class HeraldService {
|
|||||||
const firstEventPayload = firstEvent?.payload as Record<string, unknown> | undefined;
|
const firstEventPayload = firstEvent?.payload as Record<string, unknown> | undefined;
|
||||||
const metadata = firstEventPayload?.metadata as Record<string, unknown> | undefined;
|
const metadata = firstEventPayload?.metadata as Record<string, unknown> | undefined;
|
||||||
const threadId = metadata?.threadId as string | undefined;
|
const threadId = metadata?.threadId as string | undefined;
|
||||||
|
const channelId = metadata?.channelId as string | undefined;
|
||||||
|
|
||||||
if (!threadId) {
|
if (!threadId) {
|
||||||
this.logger.debug(`Job ${jobId} has no threadId, skipping broadcast`);
|
this.logger.debug(`Job ${jobId} has no threadId, skipping broadcast`);
|
||||||
@@ -95,13 +96,15 @@ export class HeraldService {
|
|||||||
try {
|
try {
|
||||||
await provider.sendThreadMessage({
|
await provider.sendThreadMessage({
|
||||||
threadId,
|
threadId,
|
||||||
|
channelId: channelId ?? "",
|
||||||
content: message,
|
content: message,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
// Log and continue — one provider failure must not block others
|
// Log and continue — one provider failure must not block others
|
||||||
|
const providerName = provider.constructor.name;
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to broadcast event ${event.type} for job ${jobId} via provider:`,
|
`Failed to broadcast event ${event.type} for job ${jobId} via ${providerName}:`,
|
||||||
error
|
error instanceof Error ? error.message : error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,14 +112,11 @@ echo ""
|
|||||||
echo "Step 2: Obtaining admin access token..."
|
echo "Step 2: Obtaining admin access token..."
|
||||||
ADMIN_LOGIN_RESPONSE=$(curl -sS -X POST "${SYNAPSE_URL}/_matrix/client/v3/login" \
|
ADMIN_LOGIN_RESPONSE=$(curl -sS -X POST "${SYNAPSE_URL}/_matrix/client/v3/login" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{
|
-d "$(jq -n \
|
||||||
\"type\": \"m.login.password\",
|
--arg user "$ADMIN_USERNAME" \
|
||||||
\"identifier\": {
|
--arg pw "$ADMIN_PASSWORD" \
|
||||||
\"type\": \"m.id.user\",
|
'{type: "m.login.password", identifier: {type: "m.id.user", user: $user}, password: $pw}')" \
|
||||||
\"user\": \"${ADMIN_USERNAME}\"
|
2>/dev/null)
|
||||||
},
|
|
||||||
\"password\": \"${ADMIN_PASSWORD}\"
|
|
||||||
}" 2>/dev/null)
|
|
||||||
|
|
||||||
ADMIN_TOKEN=$(echo "${ADMIN_LOGIN_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true)
|
ADMIN_TOKEN=$(echo "${ADMIN_LOGIN_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true)
|
||||||
|
|
||||||
@@ -140,12 +137,11 @@ echo "Step 3: Registering bot account '${BOT_USERNAME}'..."
|
|||||||
BOT_REGISTER_RESPONSE=$(curl -sS -X PUT "${SYNAPSE_URL}/_synapse/admin/v2/users/@${BOT_USERNAME}:localhost" \
|
BOT_REGISTER_RESPONSE=$(curl -sS -X PUT "${SYNAPSE_URL}/_synapse/admin/v2/users/@${BOT_USERNAME}:localhost" \
|
||||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{
|
-d "$(jq -n \
|
||||||
\"password\": \"${BOT_PASSWORD}\",
|
--arg pw "$BOT_PASSWORD" \
|
||||||
\"displayname\": \"${BOT_DISPLAY_NAME}\",
|
--arg dn "$BOT_DISPLAY_NAME" \
|
||||||
\"admin\": false,
|
'{password: $pw, displayname: $dn, admin: false, deactivated: false}')" \
|
||||||
\"deactivated\": false
|
2>/dev/null)
|
||||||
}" 2>/dev/null)
|
|
||||||
|
|
||||||
BOT_EXISTS=$(echo "${BOT_REGISTER_RESPONSE}" | python3 -c "import sys,json; d=json.load(sys.stdin); print('yes' if d.get('name') else 'no')" 2>/dev/null || echo "no")
|
BOT_EXISTS=$(echo "${BOT_REGISTER_RESPONSE}" | python3 -c "import sys,json; d=json.load(sys.stdin); print('yes' if d.get('name') else 'no')" 2>/dev/null || echo "no")
|
||||||
|
|
||||||
@@ -162,14 +158,11 @@ echo ""
|
|||||||
echo "Step 4: Obtaining bot access token..."
|
echo "Step 4: Obtaining bot access token..."
|
||||||
BOT_LOGIN_RESPONSE=$(curl -sS -X POST "${SYNAPSE_URL}/_matrix/client/v3/login" \
|
BOT_LOGIN_RESPONSE=$(curl -sS -X POST "${SYNAPSE_URL}/_matrix/client/v3/login" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{
|
-d "$(jq -n \
|
||||||
\"type\": \"m.login.password\",
|
--arg user "$BOT_USERNAME" \
|
||||||
\"identifier\": {
|
--arg pw "$BOT_PASSWORD" \
|
||||||
\"type\": \"m.id.user\",
|
'{type: "m.login.password", identifier: {type: "m.id.user", user: $user}, password: $pw}')" \
|
||||||
\"user\": \"${BOT_USERNAME}\"
|
2>/dev/null)
|
||||||
},
|
|
||||||
\"password\": \"${BOT_PASSWORD}\"
|
|
||||||
}" 2>/dev/null)
|
|
||||||
|
|
||||||
BOT_TOKEN=$(echo "${BOT_LOGIN_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true)
|
BOT_TOKEN=$(echo "${BOT_LOGIN_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user