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:
Fusion
2026-05-12 13:25:29 -07:00
committed by gsxdsm
parent 8d4835998c
commit 044b1cba2f
20 changed files with 531 additions and 53 deletions

View File

@@ -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>

View File

@@ -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();