feat(FN-4178): add room message notification delivery
This merge implements room message notifications across the system, adding a core room event type, wiring the notification dispatcher to room activity, and delivering notifications via ntfy and webhook providers with updated settings UI and API routes. Fusion-Task-Id: FN-4178
This commit is contained in:
5
.changeset/fn-4178-room-message-notification.md
Normal file
5
.changeset/fn-4178-room-message-notification.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add a new `message:room` notification event for agent assistant replies posted in chat rooms. The event is enabled by default for ntfy notifications and can be tested or toggled from Settings → Notifications alongside the existing direct-message events.
|
||||
@@ -43,11 +43,11 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
||||
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
|
||||
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
||||
| `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer <token>` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. |
|
||||
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). If you use a custom `ntfyEvents` list, this event must be present (or `ntfyEvents` must be unset so defaults apply) for agent-to-agent inbox notifications to send. |
|
||||
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. |
|
||||
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
|
||||
| `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. |
|
||||
|
||||
In **Settings → Notifications**, use **Test message notification** to exercise the full mailbox-message dispatch pipeline (`NotificationService.dispatch` → provider delivery), not just a raw ntfy POST.
|
||||
In **Settings → Notifications**, use **Test message notification** or **Send test room notification** to exercise the full message-dispatch pipeline (`NotificationService.dispatch` → provider delivery), not just a raw ntfy POST.
|
||||
|
||||
Fusion automatically falls back to ntfy's JSON publish format when a notification title or message contains non-Latin-1 characters, and truncates outgoing titles/messages to ntfy's documented size limits before sending.
|
||||
| `webhookUrl` | `string` | `undefined` | Webhook endpoint URL. Must be `http://` or `https://`. Part of legacy flat settings. |
|
||||
@@ -148,7 +148,7 @@ When `id` is `"ntfy"` in `notificationProviders`, the provider `config` supports
|
||||
| `topic` | `string` | _required_ | ntfy topic name (1–64 chars, alphanumeric + `-_`). |
|
||||
| `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. |
|
||||
| `ntfyAccessToken` | `string` | `undefined` | Optional access token. When set, provider sends `Authorization: Bearer <token>` on ntfy publishes. |
|
||||
| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. |
|
||||
| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "fallback-used" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. |
|
||||
| `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. |
|
||||
|
||||
Disable daily update checks globally:
|
||||
|
||||
@@ -344,6 +344,7 @@ describe("GlobalSettingsStore", () => {
|
||||
"planning-awaiting-input",
|
||||
"message:agent-to-user",
|
||||
"message:agent-to-agent",
|
||||
"message:room",
|
||||
"gridlock",
|
||||
"fallback-used",
|
||||
"memory-dreams-processed",
|
||||
|
||||
@@ -154,6 +154,7 @@ describe("NotificationDispatcher", () => {
|
||||
"planning-awaiting-input",
|
||||
"message:agent-to-user",
|
||||
"message:agent-to-agent",
|
||||
"message:room",
|
||||
"gridlock",
|
||||
"fallback-used",
|
||||
"memory-dreams-processed",
|
||||
|
||||
@@ -33,6 +33,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
"planning-awaiting-input",
|
||||
"message:agent-to-user",
|
||||
"message:agent-to-agent",
|
||||
"message:room",
|
||||
"gridlock",
|
||||
"fallback-used",
|
||||
"memory-dreams-processed",
|
||||
|
||||
@@ -258,7 +258,8 @@ export type NtfyNotificationEvent =
|
||||
| "fallback-used"
|
||||
| "memory-dreams-processed"
|
||||
| "message:agent-to-user"
|
||||
| "message:agent-to-agent";
|
||||
| "message:agent-to-agent"
|
||||
| "message:room";
|
||||
|
||||
/** Known notification event types. Providers may support additional custom events. */
|
||||
export const NOTIFICATION_EVENTS = [
|
||||
@@ -273,6 +274,7 @@ export const NOTIFICATION_EVENTS = [
|
||||
"memory-dreams-processed",
|
||||
"message:agent-to-user",
|
||||
"message:agent-to-agent",
|
||||
"message:room",
|
||||
] as const;
|
||||
|
||||
/** Notification event type. Known events plus provider-specific custom events. */
|
||||
|
||||
@@ -248,6 +248,7 @@ const DEFAULT_NTFY_EVENTS: NtfyNotificationEvent[] = [
|
||||
"memory-dreams-processed",
|
||||
"message:agent-to-user",
|
||||
"message:agent-to-agent",
|
||||
"message:room",
|
||||
];
|
||||
|
||||
const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: string; description: string }> = [
|
||||
@@ -262,6 +263,7 @@ const NOTIFICATION_EVENT_OPTIONS: Array<{ event: NtfyNotificationEvent; label: s
|
||||
{ event: "memory-dreams-processed", label: "DREAMS.md entry added", description: "When manual dream processing writes a new entry to project or agent DREAMS.md" },
|
||||
{ event: "message:agent-to-user", label: "Agent → user message", description: "An agent sent you a direct message" },
|
||||
{ event: "message:agent-to-agent", label: "Agent → agent message", description: "Agents are talking to each other (including replies)" },
|
||||
{ event: "message:room", label: "Agent message in room", description: "An agent posted a reply in a chat room you're watching" },
|
||||
];
|
||||
|
||||
/** Well-known experimental feature flags with display labels.
|
||||
@@ -1168,8 +1170,8 @@ export function SettingsModal({
|
||||
}
|
||||
}, [addToast, loadAuthStatus]);
|
||||
|
||||
const handleTestProviderNotification = useCallback(async (providerId: "ntfy" | "webhook" | "ntfy-message") => {
|
||||
if (providerId === "ntfy" || providerId === "ntfy-message") {
|
||||
const handleTestProviderNotification = useCallback(async (providerId: "ntfy" | "webhook" | "ntfy-message" | "ntfy-room") => {
|
||||
if (providerId === "ntfy" || providerId === "ntfy-message" || providerId === "ntfy-room") {
|
||||
if (!form.ntfyEnabled || !form.ntfyTopic || !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)) {
|
||||
return;
|
||||
}
|
||||
@@ -1205,15 +1207,21 @@ export function SettingsModal({
|
||||
}
|
||||
: providerId === "ntfy-message"
|
||||
? { messageEventType: "message:agent-to-user" }
|
||||
: {
|
||||
webhookUrl: form.webhookUrl,
|
||||
webhookFormat: form.webhookFormat || "generic",
|
||||
};
|
||||
const result = await testNotification(providerId === "ntfy-message" ? "ntfy" : providerId, config, projectId);
|
||||
: providerId === "ntfy-room"
|
||||
? { messageEventType: "message:room" }
|
||||
: {
|
||||
webhookUrl: form.webhookUrl,
|
||||
webhookFormat: form.webhookFormat || "generic",
|
||||
};
|
||||
const result = await testNotification(
|
||||
providerId === "ntfy-message" || providerId === "ntfy-room" ? "ntfy" : providerId,
|
||||
config,
|
||||
projectId,
|
||||
);
|
||||
if (result.success) {
|
||||
const providerName = providerId === "ntfy"
|
||||
? "ntfy app"
|
||||
: providerId === "ntfy-message"
|
||||
: providerId === "ntfy-message" || providerId === "ntfy-room"
|
||||
? "ntfy app inbox"
|
||||
: "webhook endpoint";
|
||||
const successMessage = `Test notification sent — check your ${providerName}!`;
|
||||
@@ -4987,6 +4995,7 @@ export function SettingsModal({
|
||||
disabled={
|
||||
testNotificationLoading["ntfy"] ||
|
||||
testNotificationLoading["ntfy-message"] ||
|
||||
testNotificationLoading["ntfy-room"] ||
|
||||
!form.ntfyEnabled ||
|
||||
!form.ntfyTopic ||
|
||||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
|
||||
@@ -5001,6 +5010,7 @@ export function SettingsModal({
|
||||
disabled={
|
||||
testNotificationLoading["ntfy"] ||
|
||||
testNotificationLoading["ntfy-message"] ||
|
||||
testNotificationLoading["ntfy-room"] ||
|
||||
!form.ntfyEnabled ||
|
||||
!form.ntfyTopic ||
|
||||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
|
||||
@@ -5008,8 +5018,23 @@ export function SettingsModal({
|
||||
>
|
||||
{testNotificationLoading["ntfy-message"] ? "Sending…" : "Test message notification"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleTestProviderNotification("ntfy-room")}
|
||||
disabled={
|
||||
testNotificationLoading["ntfy"] ||
|
||||
testNotificationLoading["ntfy-message"] ||
|
||||
testNotificationLoading["ntfy-room"] ||
|
||||
!form.ntfyEnabled ||
|
||||
!form.ntfyTopic ||
|
||||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
|
||||
}
|
||||
>
|
||||
{testNotificationLoading["ntfy-room"] ? "Sending…" : "Send test room notification"}
|
||||
</button>
|
||||
</div>
|
||||
{(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"]) && (
|
||||
{(testNotificationResult["ntfy"] || testNotificationResult["ntfy-message"] || testNotificationResult["ntfy-room"]) && (
|
||||
<div className="notification-test-feedback" aria-live="polite">
|
||||
{testNotificationResult["ntfy"] && (
|
||||
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy"].status}`}>
|
||||
@@ -5021,6 +5046,11 @@ export function SettingsModal({
|
||||
{testNotificationResult["ntfy-message"].message}
|
||||
</small>
|
||||
)}
|
||||
{testNotificationResult["ntfy-room"] && (
|
||||
<small className={`notification-test-feedback-item notification-test-feedback-item--${testNotificationResult["ntfy-room"].status}`}>
|
||||
{testNotificationResult["ntfy-room"].message}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3071,7 +3071,7 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByLabelText("Access token (optional)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows fallback, dreams, and mailbox message events for both providers", async () => {
|
||||
it("shows fallback, dreams, and mailbox/room message events for both providers", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
@@ -3081,16 +3081,20 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByLabelText("DREAMS.md entry added")).toBeInTheDocument();
|
||||
const agentToUserNtfy = screen.getByLabelText("Agent → user message") as HTMLInputElement;
|
||||
const agentToAgentNtfy = screen.getByLabelText("Agent → agent message") as HTMLInputElement;
|
||||
const roomMessageNtfy = screen.getByLabelText("Agent message in room") as HTMLInputElement;
|
||||
expect(agentToUserNtfy.checked).toBe(true);
|
||||
expect(agentToAgentNtfy.checked).toBe(true);
|
||||
expect(roomMessageNtfy.checked).toBe(true);
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Webhook notifications"));
|
||||
expect(screen.getAllByLabelText("Fallback model used (recovered)").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByLabelText("DREAMS.md entry added").length).toBeGreaterThan(0);
|
||||
const [agentToUserWebhook] = screen.getAllByLabelText("Agent → user message") as HTMLInputElement[];
|
||||
const [agentToAgentWebhook] = screen.getAllByLabelText("Agent → agent message") as HTMLInputElement[];
|
||||
const [roomMessageWebhook] = screen.getAllByLabelText("Agent message in room") as HTMLInputElement[];
|
||||
expect(agentToUserWebhook.checked).toBe(true);
|
||||
expect(agentToAgentWebhook.checked).toBe(true);
|
||||
expect(roomMessageWebhook.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("shows webhook fields when webhook provider is enabled", async () => {
|
||||
@@ -3187,6 +3191,28 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getAllByText("Test notification sent — check your ntfy app inbox!")[0].closest(".notification-test-feedback")).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
|
||||
it("calls testNotification with ntfy room-event config when room test button clicked", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
renderModal({ addToast });
|
||||
await waitForSettingsModalReady();
|
||||
await openNotificationsSection();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Send test room notification/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||
"ntfy",
|
||||
{ messageEventType: "message:room" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
"Test notification sent — check your ntfy app inbox!",
|
||||
"success",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls testNotification with webhook provider ID when webhook test button clicked", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
@@ -1872,6 +1872,39 @@ describe("POST /settings/test-notification", () => {
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ntfy provider dispatches a room message-event pipeline test when messageEventType is message:room", async () => {
|
||||
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy", messageEventType: "message:room" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
"message:room",
|
||||
expect.objectContaining({
|
||||
event: "message:room",
|
||||
metadata: expect.objectContaining({
|
||||
roomId: "test-room",
|
||||
roomName: "Test Room",
|
||||
senderName: "Fusion",
|
||||
preview: "Fusion test room notification",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ntfy provider uses config override for baseUrl", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
|
||||
@@ -1939,8 +1939,9 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
if (
|
||||
requestedMessageEventType !== "message:agent-to-user"
|
||||
&& requestedMessageEventType !== "message:agent-to-agent"
|
||||
&& requestedMessageEventType !== "message:room"
|
||||
) {
|
||||
throw badRequest("messageEventType must be message:agent-to-user or message:agent-to-agent");
|
||||
throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room");
|
||||
}
|
||||
|
||||
const notificationService = getActiveNotificationService();
|
||||
@@ -1949,21 +1950,39 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
}
|
||||
|
||||
try {
|
||||
const messageType = requestedMessageEventType.split(":")[1] ?? "agent-to-user";
|
||||
await notificationService.dispatch(requestedMessageEventType, {
|
||||
taskId: undefined,
|
||||
taskTitle: undefined,
|
||||
event: requestedMessageEventType,
|
||||
metadata: {
|
||||
messageId: `test-${crypto.randomUUID()}`,
|
||||
fromId: "system",
|
||||
fromType: "agent",
|
||||
toId: "user",
|
||||
toType: "user",
|
||||
type: messageType,
|
||||
preview: "Fusion test message notification",
|
||||
},
|
||||
});
|
||||
const messageId = `test-${crypto.randomUUID()}`;
|
||||
if (requestedMessageEventType === "message:room") {
|
||||
await notificationService.dispatch(requestedMessageEventType, {
|
||||
taskId: undefined,
|
||||
taskTitle: undefined,
|
||||
event: requestedMessageEventType,
|
||||
metadata: {
|
||||
messageId,
|
||||
roomId: "test-room",
|
||||
roomName: "Test Room",
|
||||
senderAgentId: "system",
|
||||
senderName: "Fusion",
|
||||
preview: "Fusion test room notification",
|
||||
type: "room-assistant",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const messageType = requestedMessageEventType.split(":")[1] ?? "agent-to-user";
|
||||
await notificationService.dispatch(requestedMessageEventType, {
|
||||
taskId: undefined,
|
||||
taskTitle: undefined,
|
||||
event: requestedMessageEventType,
|
||||
metadata: {
|
||||
messageId,
|
||||
fromId: "system",
|
||||
fromType: "agent",
|
||||
toId: "user",
|
||||
toType: "user",
|
||||
type: messageType,
|
||||
preview: "Fusion test message notification",
|
||||
},
|
||||
});
|
||||
}
|
||||
res.json({ success: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
|
||||
@@ -610,6 +610,12 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
|
||||
// Create ChatStore for chat session management (available for SSE event forwarding)
|
||||
const chatStore = options?.chatStore ?? new ChatStore(store.getFusionDir(), store.getDatabase());
|
||||
options?.engine?.attachChatStore?.(chatStore);
|
||||
if (typeof options?.engineManager?.getAllEngines === "function") {
|
||||
for (const engine of options.engineManager.getAllEngines().values()) {
|
||||
engine.attachChatStore?.(chatStore);
|
||||
}
|
||||
}
|
||||
|
||||
// Lets the browser explicitly release server-side SSE listeners during page
|
||||
// unload. EventSource.close() is not enough in Chrome refresh paths because
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Message, NotificationProvider, Settings, Task } from "@fusion/core";
|
||||
import type { ChatRoomMessage, Message, NotificationProvider, Settings, Task } from "@fusion/core";
|
||||
import { NotificationService } from "../notification/notification-service.js";
|
||||
import { NtfyNotificationProvider } from "../notification/ntfy-provider.js";
|
||||
import { schedulerLog } from "../logger.js";
|
||||
@@ -65,6 +65,22 @@ function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
} as Message;
|
||||
}
|
||||
|
||||
function createRoomMessage(overrides: Partial<ChatRoomMessage> = {}): ChatRoomMessage {
|
||||
return {
|
||||
id: "rmsg-1",
|
||||
roomId: "room-1",
|
||||
role: "assistant",
|
||||
content: "hello from room agent",
|
||||
thinkingOutput: null,
|
||||
metadata: null,
|
||||
attachments: [],
|
||||
senderAgentId: "agent-1",
|
||||
mentions: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("NotificationService", () => {
|
||||
it("dispatches in-review event to registered provider", async () => {
|
||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||
@@ -263,6 +279,98 @@ describe("NotificationService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("dispatches message:room from chat:room:message:added", async () => {
|
||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||
const chatStore = new EventEmitter() as EventEmitter & {
|
||||
getRoom: (id: string) => { id: string; name: string } | undefined;
|
||||
};
|
||||
chatStore.getRoom = (id: string) => (id === "room-1" ? { id, name: "Incident Room" } : undefined);
|
||||
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||
const provider: NotificationProvider = {
|
||||
getProviderId: () => "mock",
|
||||
isEventSupported: () => true,
|
||||
sendNotification,
|
||||
};
|
||||
|
||||
const service = new NotificationService(store as any, {
|
||||
chatStore: chatStore as any,
|
||||
agentNameResolver: (agentId) => (agentId === "agent-1" ? "Triage Bot" : null),
|
||||
});
|
||||
service.registerProvider(provider);
|
||||
await service.start();
|
||||
|
||||
chatStore.emit("chat:room:message:added", createRoomMessage());
|
||||
await vi.waitFor(() => {
|
||||
expect(sendNotification).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(sendNotification).toHaveBeenCalledWith(
|
||||
"message:room",
|
||||
expect.objectContaining({
|
||||
event: "message:room",
|
||||
metadata: expect.objectContaining({
|
||||
messageId: "rmsg-1",
|
||||
roomId: "room-1",
|
||||
roomName: "Incident Room",
|
||||
senderAgentId: "agent-1",
|
||||
senderName: "Triage Bot",
|
||||
preview: "hello from room agent",
|
||||
type: "room-assistant",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("dispatches room notifications when chat store attaches after start", async () => {
|
||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||
const chatStore = new EventEmitter() as EventEmitter & {
|
||||
getRoom: (id: string) => { id: string; name: string } | undefined;
|
||||
};
|
||||
chatStore.getRoom = (id: string) => (id === "room-1" ? { id, name: "Incident Room" } : undefined);
|
||||
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||
const provider: NotificationProvider = {
|
||||
getProviderId: () => "mock",
|
||||
isEventSupported: () => true,
|
||||
sendNotification,
|
||||
};
|
||||
|
||||
const service = new NotificationService(store as any, {
|
||||
agentNameResolver: () => "Triage Bot",
|
||||
});
|
||||
service.registerProvider(provider);
|
||||
await service.start();
|
||||
service.attachChatStore(chatStore as any);
|
||||
|
||||
chatStore.emit("chat:room:message:added", createRoomMessage());
|
||||
await vi.waitFor(() => {
|
||||
expect(sendNotification).toHaveBeenCalledWith(
|
||||
"message:room",
|
||||
expect.objectContaining({ event: "message:room" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores non-agent or non-assistant room messages", async () => {
|
||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||
const chatStore = new EventEmitter();
|
||||
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||
const provider: NotificationProvider = {
|
||||
getProviderId: () => "mock",
|
||||
isEventSupported: () => true,
|
||||
sendNotification,
|
||||
};
|
||||
|
||||
const service = new NotificationService(store as any, { chatStore: chatStore as any });
|
||||
service.registerProvider(provider);
|
||||
await service.start();
|
||||
|
||||
chatStore.emit("chat:room:message:added", createRoomMessage({ role: "user" }));
|
||||
chatStore.emit("chat:room:message:added", createRoomMessage({ id: "rmsg-2", senderAgentId: null }));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches even when agent name resolution fails", async () => {
|
||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||
const messageStore = new EventEmitter();
|
||||
|
||||
@@ -73,6 +73,7 @@ describe("Ntfy notifier helpers", () => {
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("fallback-used");
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-user");
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("message:agent-to-agent");
|
||||
expect(DEFAULT_NTFY_EVENTS).toContain("message:room");
|
||||
});
|
||||
|
||||
it("checks planning-awaiting-input event enablement", () => {
|
||||
@@ -106,6 +107,18 @@ describe("Ntfy notifier helpers", () => {
|
||||
}),
|
||||
).toBe("http://localhost:4040/?project=proj-1&view=mailbox&mailbox-message=msg-1#message-msg-1");
|
||||
});
|
||||
|
||||
it("builds room message deep links", () => {
|
||||
expect(
|
||||
buildNtfyClickUrl({
|
||||
dashboardHost: "http://localhost:4040/",
|
||||
projectId: "proj-1",
|
||||
roomId: "room-1",
|
||||
messageId: "msg-1",
|
||||
view: "rooms",
|
||||
}),
|
||||
).toBe("http://localhost:4040/?project=proj-1&view=rooms&room=room-1#message-msg-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendNtfyNotificationWithResult", () => {
|
||||
|
||||
@@ -46,6 +46,7 @@ describe("NtfyNotificationProvider", () => {
|
||||
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
|
||||
["message:agent-to-user", "New message from Triage Bot", "Triage Bot → you: preview text", "high"],
|
||||
["message:agent-to-agent", "Triage Bot → Executor Bot", "Triage Bot messaged Executor Bot: preview text", "default"],
|
||||
["message:room", "#Incident Room — Triage Bot", "Triage Bot in #Incident Room: preview text", "default"],
|
||||
])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => {
|
||||
await provider.sendNotification(event as any, {
|
||||
taskId: "FN-1",
|
||||
@@ -56,8 +57,12 @@ describe("NtfyNotificationProvider", () => {
|
||||
toId: "agent-2",
|
||||
fromName: "Triage Bot",
|
||||
toName: "Executor Bot",
|
||||
senderAgentId: "agent-1",
|
||||
senderName: "Triage Bot",
|
||||
preview: "preview text",
|
||||
messageId: "msg-1",
|
||||
roomId: "room-1",
|
||||
roomName: "Incident Room",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -87,6 +92,7 @@ describe("NtfyNotificationProvider", () => {
|
||||
expect(provider.isEventSupported("fallback-used" as any)).toBe(true);
|
||||
expect(provider.isEventSupported("message:agent-to-user" as any)).toBe(true);
|
||||
expect(provider.isEventSupported("message:agent-to-agent" as any)).toBe(true);
|
||||
expect(provider.isEventSupported("message:room" as any)).toBe(true);
|
||||
expect(provider.isEventSupported("custom-event" as any)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -124,6 +130,28 @@ describe("NtfyNotificationProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses room deep link for room notifications", async () => {
|
||||
await provider.sendNotification("message:room" as any, {
|
||||
event: "message:room" as any,
|
||||
metadata: {
|
||||
messageId: "msg-room",
|
||||
roomId: "room-1",
|
||||
roomName: "Incident Room",
|
||||
senderAgentId: "agent-1",
|
||||
senderName: "Triage Bot",
|
||||
preview: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.buildNtfyClickUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
roomId: "room-1",
|
||||
messageId: "msg-room",
|
||||
view: "rooms",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses mailbox deep link when message is not task-bound", async () => {
|
||||
await provider.sendNotification("message:agent-to-agent" as any, {
|
||||
event: "message:agent-to-agent" as any,
|
||||
|
||||
@@ -167,6 +167,7 @@ describe("WebhookNotificationProvider", () => {
|
||||
["fallback-used", "Fusion recovered by switching from"],
|
||||
["message:agent-to-user", "From: Triage Bot → You: hello"],
|
||||
["message:agent-to-agent", "From: Triage Bot → To: Executor Bot: hello"],
|
||||
["message:room", "In #Incident Room: Triage Bot: hello"],
|
||||
["unknown-event", 'Event "unknown-event" for task My Task'],
|
||||
])("message formatting for %s", async (event, expectedPart) => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||
@@ -176,7 +177,17 @@ describe("WebhookNotificationProvider", () => {
|
||||
taskId: "FN-1",
|
||||
taskTitle: "My Task",
|
||||
event,
|
||||
metadata: { fromId: "agent-1", toId: "agent-2", fromName: "Triage Bot", toName: "Executor Bot", preview: "hello" },
|
||||
metadata: {
|
||||
fromId: "agent-1",
|
||||
toId: "agent-2",
|
||||
fromName: "Triage Bot",
|
||||
toName: "Executor Bot",
|
||||
senderAgentId: "agent-1",
|
||||
senderName: "Triage Bot",
|
||||
roomId: "room-1",
|
||||
roomName: "Incident Room",
|
||||
preview: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
@@ -184,6 +195,40 @@ describe("WebhookNotificationProvider", () => {
|
||||
expect(body.text).toContain(expectedPart);
|
||||
});
|
||||
|
||||
it("includes room metadata and room deep link for room notifications", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||
await provider.initialize({
|
||||
webhookUrl: "https://example.com/hook",
|
||||
webhookFormat: "generic",
|
||||
dashboardHost: "http://dash",
|
||||
projectId: "p1",
|
||||
});
|
||||
|
||||
await provider.sendNotification("message:room", {
|
||||
event: "message:room",
|
||||
metadata: {
|
||||
messageId: "msg-room",
|
||||
roomId: "room-1",
|
||||
roomName: "Incident Room",
|
||||
senderAgentId: "agent-1",
|
||||
senderName: "Triage Bot",
|
||||
preview: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const payload = JSON.parse(String(requestInit.body));
|
||||
expect(payload.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
roomId: "room-1",
|
||||
roomName: "Incident Room",
|
||||
senderAgentId: "agent-1",
|
||||
senderName: "Triage Bot",
|
||||
}),
|
||||
);
|
||||
expect(payload.clickUrl).toBe("http://dash/?project=p1&view=rooms&room=room-1#message-msg-room");
|
||||
});
|
||||
|
||||
it("title fallback uses taskId and truncated description snippet", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "slack" });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ChatRoomMessage,
|
||||
Column,
|
||||
MergeResult,
|
||||
Message,
|
||||
@@ -21,6 +22,8 @@ export interface NotificationServiceOptions {
|
||||
ntfyBaseUrl?: string;
|
||||
/** Optional message store for mailbox message notifications */
|
||||
messageStore?: NotificationMessageStore;
|
||||
/** Optional chat store for room message notifications */
|
||||
chatStore?: NotificationChatStore;
|
||||
/** Resolve human-readable name for an agent ID used in message notifications */
|
||||
agentNameResolver?: (agentId: string) => Promise<string | null> | string | null;
|
||||
}
|
||||
@@ -36,10 +39,17 @@ interface NotificationMessageStore {
|
||||
off?(event: "message:sent", listener: (message: Message) => void): void;
|
||||
}
|
||||
|
||||
export interface NotificationChatStore {
|
||||
on(event: "chat:room:message:added", listener: (message: ChatRoomMessage) => void): void;
|
||||
off?(event: "chat:room:message:added", listener: (message: ChatRoomMessage) => void): void;
|
||||
getRoom?(id: string): { id: string; name: string } | undefined;
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
private readonly dispatcher = new NotificationDispatcher();
|
||||
private readonly notifiedEvents = new Set<string>();
|
||||
private started = false;
|
||||
private chatStore: NotificationChatStore | undefined;
|
||||
private notificationsEnabled = false;
|
||||
private ntfyProvider?: NtfyNotificationProvider;
|
||||
private webhookProvider?: WebhookNotificationProvider;
|
||||
@@ -48,7 +58,19 @@ export class NotificationService {
|
||||
constructor(
|
||||
private readonly store: NotificationServiceStore,
|
||||
private readonly options: NotificationServiceOptions = {},
|
||||
) {}
|
||||
) {
|
||||
this.chatStore = options.chatStore;
|
||||
}
|
||||
|
||||
attachChatStore(chatStore: NotificationChatStore): void {
|
||||
if (this.chatStore && this.chatStore !== chatStore) {
|
||||
this.detachChatStoreListener(this.chatStore);
|
||||
}
|
||||
this.chatStore = chatStore;
|
||||
if (this.started) {
|
||||
this.chatStore.on("chat:room:message:added", this.handleRoomMessageAdded);
|
||||
}
|
||||
}
|
||||
|
||||
registerProvider(provider: NotificationProvider): void {
|
||||
this.dispatcher.registerProvider(provider);
|
||||
@@ -71,8 +93,8 @@ export class NotificationService {
|
||||
this.store.on("task:merged", this.handleTaskMerged);
|
||||
this.store.on("settings:updated", this.handleSettingsUpdated);
|
||||
this.options.messageStore?.on("message:sent", this.handleMessageSent);
|
||||
|
||||
this.started = true;
|
||||
this.chatStore?.on("chat:room:message:added", this.handleRoomMessageAdded);
|
||||
schedulerLog.log("NotificationService started");
|
||||
}
|
||||
|
||||
@@ -89,6 +111,7 @@ export class NotificationService {
|
||||
if (typeof this.options.messageStore?.off === "function") {
|
||||
this.options.messageStore.off("message:sent", this.handleMessageSent);
|
||||
}
|
||||
this.detachChatStoreListener(this.chatStore);
|
||||
}
|
||||
|
||||
await this.dispatcher.shutdownAll();
|
||||
@@ -246,6 +269,10 @@ export class NotificationService {
|
||||
void this.handleMessageSentAsync(message);
|
||||
};
|
||||
|
||||
private handleRoomMessageAdded = (message: ChatRoomMessage): void => {
|
||||
void this.handleRoomMessageAddedAsync(message);
|
||||
};
|
||||
|
||||
private async handleMessageSentAsync(message: Message): Promise<void> {
|
||||
schedulerLog.log(
|
||||
`NotificationService.handleMessageSent messageId=${message.id} type=${message.type} notificationsEnabled=${String(this.notificationsEnabled)} hasNtfyProvider=${String(Boolean(this.ntfyProvider))}`,
|
||||
@@ -267,9 +294,7 @@ export class NotificationService {
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = message.content.length > 100
|
||||
? `${message.content.slice(0, 100)}…`
|
||||
: message.content;
|
||||
const preview = this.createPreview(message.content);
|
||||
|
||||
const taskId = typeof message.metadata?.taskId === "string" ? message.metadata.taskId : undefined;
|
||||
|
||||
@@ -299,6 +324,44 @@ export class NotificationService {
|
||||
);
|
||||
}
|
||||
|
||||
private async handleRoomMessageAddedAsync(message: ChatRoomMessage): Promise<void> {
|
||||
schedulerLog.log(
|
||||
`NotificationService.handleRoomMessageAdded messageId=${message.id} roomId=${message.roomId} role=${message.role} notificationsEnabled=${String(this.notificationsEnabled)}`,
|
||||
);
|
||||
|
||||
if (message.role !== "assistant" || message.senderAgentId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.notificationsEnabled) {
|
||||
await this.refreshNotificationState("chat:room:message:added");
|
||||
if (!this.notificationsEnabled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const senderName = await this.resolveAgentName("agent", message.senderAgentId, "from");
|
||||
const roomName = this.chatStore?.getRoom?.(message.roomId)?.name;
|
||||
const preview = this.createPreview(message.content);
|
||||
|
||||
this.maybeNotify(message.id, "message:room", {
|
||||
event: "message:room",
|
||||
metadata: {
|
||||
messageId: message.id,
|
||||
roomId: message.roomId,
|
||||
...(roomName ? { roomName } : {}),
|
||||
senderAgentId: message.senderAgentId,
|
||||
...(senderName ? { senderName } : {}),
|
||||
preview,
|
||||
type: "room-assistant",
|
||||
},
|
||||
});
|
||||
|
||||
schedulerLog.log(
|
||||
`NotificationService.handleRoomMessageAdded scheduled eventType=message:room messageId=${message.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveAgentName(
|
||||
participantType: Message["fromType"],
|
||||
participantId: string,
|
||||
@@ -326,6 +389,16 @@ export class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
private createPreview(content: string): string {
|
||||
return content.length > 100 ? `${content.slice(0, 100)}…` : content;
|
||||
}
|
||||
|
||||
private detachChatStoreListener(chatStore: NotificationChatStore | undefined): void {
|
||||
if (typeof chatStore?.off === "function") {
|
||||
chatStore.off("chat:room:message:added", this.handleRoomMessageAdded);
|
||||
}
|
||||
}
|
||||
|
||||
private setNotificationsEnabledFromSettings(settings: Settings): void {
|
||||
this.notificationsEnabled = Boolean(
|
||||
(settings.ntfyEnabled && settings.ntfyTopic) ||
|
||||
|
||||
@@ -39,7 +39,8 @@ type SupportedNtfyEvent =
|
||||
| "planning-awaiting-input"
|
||||
| "fallback-used"
|
||||
| "message:agent-to-user"
|
||||
| "message:agent-to-agent";
|
||||
| "message:agent-to-agent"
|
||||
| "message:room";
|
||||
|
||||
const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
||||
"in-review",
|
||||
@@ -51,6 +52,7 @@ const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
||||
"fallback-used",
|
||||
"message:agent-to-user",
|
||||
"message:agent-to-agent",
|
||||
"message:room",
|
||||
]);
|
||||
|
||||
export function resolveParticipantLabel(
|
||||
@@ -67,6 +69,24 @@ export function resolveParticipantLabel(
|
||||
return id.length > 0 ? id : kind === "from" ? "agent" : "recipient";
|
||||
}
|
||||
|
||||
function resolveRoomSenderLabel(metadata: NotificationPayload["metadata"] | undefined): string {
|
||||
const senderName = typeof metadata?.senderName === "string" ? metadata.senderName.trim() : "";
|
||||
if (senderName.length > 0) {
|
||||
return senderName;
|
||||
}
|
||||
const senderAgentId = typeof metadata?.senderAgentId === "string" ? metadata.senderAgentId.trim() : "";
|
||||
return senderAgentId.length > 0 ? senderAgentId : "agent";
|
||||
}
|
||||
|
||||
function resolveRoomLabel(metadata: NotificationPayload["metadata"] | undefined): string {
|
||||
const roomName = typeof metadata?.roomName === "string" ? metadata.roomName.trim() : "";
|
||||
if (roomName.length > 0) {
|
||||
return roomName;
|
||||
}
|
||||
const roomId = typeof metadata?.roomId === "string" ? metadata.roomId.trim() : "";
|
||||
return roomId.length > 0 ? roomId : "room";
|
||||
}
|
||||
|
||||
export class NtfyNotificationProvider implements NotificationProvider {
|
||||
private config?: NtfyProviderConfig;
|
||||
private abortController: AbortController | null = null;
|
||||
@@ -137,14 +157,25 @@ export class NtfyNotificationProvider implements NotificationProvider {
|
||||
const replyToMessageId = typeof payload.metadata?.replyToMessageId === "string"
|
||||
? payload.metadata.replyToMessageId
|
||||
: undefined;
|
||||
const roomSenderLabel = resolveRoomSenderLabel(payload.metadata);
|
||||
const roomLabel = resolveRoomLabel(payload.metadata);
|
||||
const roomId = typeof payload.metadata?.roomId === "string" ? payload.metadata.roomId : undefined;
|
||||
|
||||
const clickUrl = buildNtfyClickUrl({
|
||||
dashboardHost: this.config.dashboardHost,
|
||||
projectId: this.config.projectId,
|
||||
taskId: payload.taskId,
|
||||
messageId,
|
||||
view: "mailbox",
|
||||
});
|
||||
const clickUrl = event === "message:room"
|
||||
? buildNtfyClickUrl({
|
||||
dashboardHost: this.config.dashboardHost,
|
||||
projectId: this.config.projectId,
|
||||
roomId,
|
||||
messageId,
|
||||
view: "rooms",
|
||||
})
|
||||
: buildNtfyClickUrl({
|
||||
dashboardHost: this.config.dashboardHost,
|
||||
projectId: this.config.projectId,
|
||||
taskId: payload.taskId,
|
||||
messageId,
|
||||
view: "mailbox",
|
||||
});
|
||||
|
||||
const contentByEvent: Record<SupportedNtfyEvent, { title: string; message: string; priority: "default" | "high" }> = {
|
||||
"in-review": {
|
||||
@@ -192,6 +223,11 @@ export class NtfyNotificationProvider implements NotificationProvider {
|
||||
message: `${senderLabel} messaged ${recipientLabel}: ${preview}`,
|
||||
priority: "default",
|
||||
},
|
||||
"message:room": {
|
||||
title: `#${roomLabel} — ${roomSenderLabel}`,
|
||||
message: `${roomSenderLabel} in #${roomLabel}: ${preview}`,
|
||||
priority: "default",
|
||||
},
|
||||
};
|
||||
|
||||
const content = contentByEvent[event as SupportedNtfyEvent];
|
||||
|
||||
@@ -34,6 +34,24 @@ function resolveParticipantLabel(
|
||||
return id.length > 0 ? id : kind === "from" ? "agent" : "recipient";
|
||||
}
|
||||
|
||||
function resolveRoomSenderLabel(metadata: NotificationPayload["metadata"] | undefined): string {
|
||||
const senderName = typeof metadata?.senderName === "string" ? metadata.senderName.trim() : "";
|
||||
if (senderName.length > 0) {
|
||||
return senderName;
|
||||
}
|
||||
const senderAgentId = typeof metadata?.senderAgentId === "string" ? metadata.senderAgentId.trim() : "";
|
||||
return senderAgentId.length > 0 ? senderAgentId : "agent";
|
||||
}
|
||||
|
||||
function resolveRoomLabel(metadata: NotificationPayload["metadata"] | undefined): string {
|
||||
const roomName = typeof metadata?.roomName === "string" ? metadata.roomName.trim() : "";
|
||||
if (roomName.length > 0) {
|
||||
return roomName;
|
||||
}
|
||||
const roomId = typeof metadata?.roomId === "string" ? metadata.roomId.trim() : "";
|
||||
return roomId.length > 0 ? roomId : "room";
|
||||
}
|
||||
|
||||
export class WebhookNotificationProvider implements NotificationProvider {
|
||||
private config: WebhookProviderConfig | null = null;
|
||||
private abortController: AbortController | null = null;
|
||||
@@ -167,6 +185,12 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
||||
const preview = typeof payload.metadata?.preview === "string" ? payload.metadata.preview : "(no preview)";
|
||||
return `From: ${from} → To: ${to}: ${preview}`;
|
||||
}
|
||||
case "message:room": {
|
||||
const roomName = resolveRoomLabel(payload.metadata);
|
||||
const senderLabel = resolveRoomSenderLabel(payload.metadata);
|
||||
const preview = typeof payload.metadata?.preview === "string" ? payload.metadata.preview : "(no preview)";
|
||||
return `In #${roomName}: ${senderLabel}: ${preview}`;
|
||||
}
|
||||
default:
|
||||
return `Event "${event}" for task ${identifier}`;
|
||||
}
|
||||
@@ -196,9 +220,12 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
||||
}
|
||||
|
||||
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
|
||||
const roomId = typeof payload.metadata?.roomId === "string" ? payload.metadata.roomId : undefined;
|
||||
|
||||
const fromLabel = resolveParticipantLabel(payload.metadata, "from");
|
||||
const toLabel = resolveParticipantLabel(payload.metadata, "to");
|
||||
const roomLabel = resolveRoomLabel(payload.metadata);
|
||||
const roomSenderLabel = resolveRoomSenderLabel(payload.metadata);
|
||||
|
||||
return {
|
||||
event: payload.event,
|
||||
@@ -215,14 +242,28 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
||||
toName: typeof payload.metadata?.toName === "string" ? payload.metadata.toName : toLabel,
|
||||
}
|
||||
: {}),
|
||||
...(payload.event === "message:room"
|
||||
? {
|
||||
roomName: typeof payload.metadata?.roomName === "string" ? payload.metadata.roomName : roomLabel,
|
||||
senderName: typeof payload.metadata?.senderName === "string" ? payload.metadata.senderName : roomSenderLabel,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
clickUrl: buildNtfyClickUrl({
|
||||
dashboardHost: this.config.dashboardHost,
|
||||
projectId: this.config.projectId,
|
||||
taskId: payload.taskId,
|
||||
messageId,
|
||||
view: "mailbox",
|
||||
}),
|
||||
clickUrl: payload.event === "message:room"
|
||||
? buildNtfyClickUrl({
|
||||
dashboardHost: this.config.dashboardHost,
|
||||
projectId: this.config.projectId,
|
||||
roomId,
|
||||
messageId,
|
||||
view: "rooms",
|
||||
})
|
||||
: buildNtfyClickUrl({
|
||||
dashboardHost: this.config.dashboardHost,
|
||||
projectId: this.config.projectId,
|
||||
taskId: payload.taskId,
|
||||
messageId,
|
||||
view: "mailbox",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
|
||||
"fallback-used",
|
||||
"message:agent-to-user",
|
||||
"message:agent-to-agent",
|
||||
"message:room",
|
||||
] as const;
|
||||
|
||||
export interface NtfyNotificationConfigInput {
|
||||
@@ -150,9 +151,10 @@ export function buildNtfyClickUrl(options: {
|
||||
projectId?: string;
|
||||
taskId?: string;
|
||||
messageId?: string;
|
||||
roomId?: string;
|
||||
view?: string;
|
||||
}): string | undefined {
|
||||
const { dashboardHost, projectId, taskId, messageId, view } = options;
|
||||
const { dashboardHost, projectId, taskId, messageId, roomId, view } = options;
|
||||
if (!dashboardHost) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -165,6 +167,9 @@ export function buildNtfyClickUrl(options: {
|
||||
}
|
||||
if (taskId) {
|
||||
queryParts.push(`task=${encodeURIComponent(taskId)}`);
|
||||
} else if (roomId) {
|
||||
queryParts.push(`view=${encodeURIComponent(view ?? "rooms")}`);
|
||||
queryParts.push(`room=${encodeURIComponent(roomId)}`);
|
||||
} else if (messageId) {
|
||||
queryParts.push(`view=${encodeURIComponent(view ?? "mailbox")}`);
|
||||
queryParts.push(`mailbox-message=${encodeURIComponent(messageId)}`);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PrMonitor } from "./pr-monitor.js";
|
||||
import { PrCommentHandler } from "./pr-comment-handler.js";
|
||||
import { NtfyNotifier } from "./notifier.js";
|
||||
import { NotificationService } from "./notification/index.js";
|
||||
import type { NotificationChatStore } from "./notification/notification-service.js";
|
||||
import { GridlockDetector } from "./gridlock-detector.js";
|
||||
import { CronRunner, createAiPromptExecutor } from "./cron-runner.js";
|
||||
import type { RoutineRunner } from "./routine-runner.js";
|
||||
@@ -601,6 +602,10 @@ export class ProjectEngine {
|
||||
return this.runtime.getMessageStore();
|
||||
}
|
||||
|
||||
attachChatStore(chatStore: NotificationChatStore): void {
|
||||
this.notificationService?.attachChatStore(chatStore);
|
||||
}
|
||||
|
||||
/** Get the HeartbeatMonitor (if initialized). */
|
||||
getHeartbeatMonitor() {
|
||||
return this.runtime.getHeartbeatMonitor();
|
||||
|
||||
Reference in New Issue
Block a user