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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user