fix(FN-4127): harden ntfy notification delivery

- Fall back to ntfy JSON publish requests for unicode titles/messages while preserving auth and click metadata
- Truncate ntfy titles and UTF-8 message bodies to documented limits without splitting surrogate pairs
- Extend notifier and provider tests to cover unicode mailbox events, truncation, and end-to-end message notification delivery
- Document the ntfy encoding/truncation behavior and add a patch changeset for @runfusion/fusion

Fusion-Task-Id: FN-4127
This commit is contained in:
Fusion
2026-05-12 09:41:21 -07:00
committed by gsxdsm
parent 93afb73397
commit 7a982a2a5c
7 changed files with 266 additions and 23 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix ntfy notifications so unicode mailbox titles deliver correctly and oversized titles/messages are truncated before publish.

View File

@@ -48,6 +48,8 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `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.
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. |
| `webhookFormat` | `"slack" \| "discord" \| "generic"` | `"generic"` | Webhook payload format. Part of legacy flat settings. |
| `webhookEvents` | `string[]` | `[]` | Event filter for webhook notifications. Empty/omitted means all events. Part of legacy flat settings. |

View File

@@ -61,11 +61,14 @@ describe("message notification pipeline integration", () => {
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.sh/test-topic");
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.sh/");
const firstOptions = fetchSpy.mock.calls[0]?.[1] as RequestInit;
const firstHeaders = firstOptions.headers as Record<string, string>;
expect(firstHeaders.Title).toBe("agent-A → agent-B");
expect(String(firstOptions.body)).toContain("agent-A messaged agent-B: hi from agent A");
expect(firstHeaders["Content-Type"]).toBe("application/json");
const firstPayload = JSON.parse(String(firstOptions.body)) as { title: string; message: string; topic: string };
expect(firstPayload.topic).toBe("test-topic");
expect(firstPayload.title).toBe("agent-A → agent-B");
expect(firstPayload.message).toContain("agent-A messaged agent-B: hi from agent A");
messageStore.sendMessage({
fromId: "agent-A",
@@ -80,8 +83,11 @@ describe("message notification pipeline integration", () => {
await vi.waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
const replyHeaders = (fetchSpy.mock.calls[1]?.[1] as RequestInit).headers as Record<string, string>;
expect(fetchSpy.mock.calls[1]?.[0]).toBe("https://ntfy.sh/test-topic");
const replyRequest = fetchSpy.mock.calls[1]?.[1] as RequestInit;
const replyHeaders = replyRequest.headers as Record<string, string>;
expect(replyHeaders.Title).toBe("Re: reply preview");
expect(String(replyRequest.body)).toContain("agent-A messaged agent-B: reply preview");
await taskStore.updateGlobalSettings({ ntfyEvents: ["message:agent-to-user"] });
@@ -147,9 +153,13 @@ describe("message notification pipeline integration", () => {
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.sh/");
const request = fetchSpy.mock.calls[0]?.[1] as RequestInit;
const headers = request.headers as Record<string, string>;
expect(headers.Title).toBe("New message from agent-A");
expect(String(request.body)).toContain("agent-A → you: hello user");
expect(headers["Content-Type"]).toBe("application/json");
const payload = JSON.parse(String(request.body)) as { title: string; message: string; topic: string };
expect(payload.topic).toBe("test-topic");
expect(payload.title).toBe("New message from agent-A");
expect(payload.message).toContain("agent-A → you: hello user");
});
});

View File

@@ -53,22 +53,25 @@ describe("message notification pipeline", () => {
await vi.waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.sh/test-topic");
const firstHeaders = (fetchSpy.mock.calls[0]?.[1] as RequestInit).headers as Record<string, string>;
expect(firstHeaders.Title).toContain("Triage Bot");
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.sh/");
const firstRequest = fetchSpy.mock.calls[0]?.[1] as RequestInit;
const firstHeaders = firstRequest.headers as Record<string, string>;
expect(firstHeaders["Content-Type"]).toBe("application/json");
expect(firstHeaders.Authorization).toBe("Bearer token-123");
const firstBody = String((fetchSpy.mock.calls[0]?.[1] as RequestInit).body);
expect(firstBody).toContain("hi");
const firstBody = JSON.parse(String(firstRequest.body)) as { title: string; message: string; topic: string };
expect(firstBody.topic).toBe("test-topic");
expect(firstBody.title).toContain("Triage Bot");
expect(firstBody.message).toContain("hi");
messageStore.sendMessage("agent-to-agent", "relay");
await vi.waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
const secondHeaders = (fetchSpy.mock.calls[1]?.[1] as RequestInit).headers as Record<string, string>;
expect(secondHeaders.Title).toContain("Triage Bot → Executor Bot");
const secondBody = String((fetchSpy.mock.calls[1]?.[1] as RequestInit).body);
expect(secondBody).toContain("relay");
expect(fetchSpy.mock.calls[1]?.[0]).toBe("https://ntfy.sh/");
const secondBody = JSON.parse(String((fetchSpy.mock.calls[1]?.[1] as RequestInit).body)) as { title: string; message: string };
expect(secondBody.title).toContain("Triage Bot → Executor Bot");
expect(secondBody.message).toContain("relay");
messageStore.sendMessage("user-to-agent", "ignore me");
await new Promise((resolve) => setTimeout(resolve, 0));

View File

@@ -8,6 +8,7 @@ import {
isNtfyEventEnabled,
resolveNtfyEvents,
notifyFallbackUsed,
sendNtfyNotificationWithResult,
} from "../notifier.js";
import { NotificationService } from "../notification/notification-service.js";
@@ -107,6 +108,128 @@ describe("Ntfy notifier helpers", () => {
});
});
describe("sendNtfyNotificationWithResult", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
new Headers(init?.headers);
return { ok: true, status: 200, statusText: "OK" } as Response;
});
global.fetch = fetchMock;
});
it("calling with a unicode title does not throw and posts JSON with auth preserved", async () => {
const signal = new AbortController().signal;
await expect(sendNtfyNotificationWithResult({
ntfyBaseUrl: "https://ntfy.sh",
ntfyAccessToken: "secret-token",
topic: "test-topic",
title: "Triage Bot → Executor Bot",
message: "Triage Bot → you: preview text",
clickUrl: "https://fusion.example.com/?task=FN-1",
signal,
})).resolves.toEqual({ ok: true, status: 200, statusText: "OK" });
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/",
expect.objectContaining({
method: "POST",
signal,
headers: expect.objectContaining({
"Content-Type": "application/json",
Priority: "default",
Authorization: "Bearer secret-token",
}),
}),
);
const request = fetchMock.mock.calls[0][1] as RequestInit;
expect(JSON.parse(String(request.body))).toEqual({
topic: "test-topic",
title: "Triage Bot → Executor Bot",
message: "Triage Bot → you: preview text",
priority: "default",
click: "https://fusion.example.com/?task=FN-1",
});
});
it("keeps the legacy text/plain header path for pure ASCII titles", async () => {
const signal = new AbortController().signal;
await sendNtfyNotificationWithResult({
ntfyBaseUrl: "https://ntfy.sh",
topic: "ascii-topic",
title: "Task FN-1 merged",
message: "Task \"Example\" has been merged to main",
clickUrl: "https://fusion.example.com/?task=FN-1",
signal,
});
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/ascii-topic",
expect.objectContaining({
method: "POST",
signal,
headers: expect.objectContaining({
"Content-Type": "text/plain",
Title: "Task FN-1 merged",
Priority: "default",
Click: "https://fusion.example.com/?task=FN-1",
}),
body: "Task \"Example\" has been merged to main",
}),
);
});
it("truncates overlong titles to 250 characters with an ellipsis", async () => {
await sendNtfyNotificationWithResult({
ntfyBaseUrl: "https://ntfy.sh",
topic: "test-topic",
title: `${"T".repeat(260)}`,
message: "Preview text",
});
const request = fetchMock.mock.calls[0][1] as RequestInit;
const payload = JSON.parse(String(request.body)) as { title: string };
expect(Array.from(payload.title)).toHaveLength(250);
expect(payload.title.endsWith("…")).toBe(true);
});
it("truncates overlong messages to fit within 4096 UTF-8 bytes with an ellipsis", async () => {
await sendNtfyNotificationWithResult({
ntfyBaseUrl: "https://ntfy.sh",
topic: "test-topic",
title: "Triage Bot → Executor Bot",
message: "é".repeat(3000),
});
const request = fetchMock.mock.calls[0][1] as RequestInit;
const payload = JSON.parse(String(request.body)) as { message: string };
expect(Buffer.byteLength(payload.message, "utf8")).toBeLessThanOrEqual(4096);
expect(payload.message.endsWith("…")).toBe(true);
});
it("does not split a surrogate pair when truncating overlong messages", async () => {
await sendNtfyNotificationWithResult({
ntfyBaseUrl: "https://ntfy.sh",
topic: "test-topic",
title: "Triage Bot → Executor Bot",
message: `${"𝐀".repeat(1024)}Z`,
});
const request = fetchMock.mock.calls[0][1] as RequestInit;
const payload = JSON.parse(String(request.body)) as { message: string };
const characters = Array.from(payload.message);
expect(Buffer.byteLength(payload.message, "utf8")).toBeLessThanOrEqual(4096);
expect(characters.at(-1)).toBe("…");
expect(characters.at(-2)).toBe("𝐀");
expect(payload.message.endsWith("𝐀…")).toBe(true);
expect(payload.message).not.toContain("<22>");
});
});
describe("NtfyNotifier", () => {
let store: MockTaskStore;
let notifier: NtfyNotifier;

View File

@@ -139,6 +139,48 @@ describe("NtfyNotificationProvider", () => {
);
});
it("passes unicode mailbox content through verbatim for agent-to-user notifications", async () => {
await provider.sendNotification("message:agent-to-user" as any, {
taskId: "FN-1",
taskTitle: "T",
event: "message:agent-to-user" as any,
metadata: {
messageId: "msg-1",
fromId: "agent-1",
fromName: "Triage Bot",
preview: "preview text",
},
});
expect(mocks.sendNtfyNotificationWithResult).toHaveBeenCalledWith(
expect.objectContaining({
title: "New message from Triage Bot",
message: "Triage Bot → you: preview text",
}),
);
});
it("passes unicode mailbox content through verbatim for agent-to-agent notifications", async () => {
await provider.sendNotification("message:agent-to-agent" as any, {
event: "message:agent-to-agent" as any,
metadata: {
messageId: "msg-2",
fromId: "agent-1",
toId: "agent-2",
fromName: "Triage Bot",
toName: "Executor Bot",
preview: "preview text",
},
});
expect(mocks.sendNtfyNotificationWithResult).toHaveBeenCalledWith(
expect.objectContaining({
title: "Triage Bot → Executor Bot",
message: "Triage Bot messaged Executor Bot: preview text",
}),
);
});
it("uses Re: title for agent-to-agent replies", async () => {
await provider.sendNotification("message:agent-to-agent" as any, {
event: "message:agent-to-agent" as any,

View File

@@ -16,6 +16,9 @@ export type NtfyNotificationPriority = "low" | "default" | "high" | "urgent";
const DEFAULT_NTFY_BASE_URL = "https://ntfy.sh";
const GRIDLOCK_NOTIFICATION_COOLDOWN_MS = 15 * 60 * 1000;
const NTFY_TITLE_MAX = 250;
// ntfy documents a 4 KiB message body limit; reserve room for an ellipsis when truncating UTF-8 payloads.
const NTFY_MESSAGE_MAX = 4096;
export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
"in-review",
@@ -93,6 +96,47 @@ function resolveNtfyBaseUrl(baseUrl: string | undefined, fallback = DEFAULT_NTFY
return trimmed.replace(/\/+$/, "");
}
function isLatin1Safe(value: string): boolean {
for (const char of value) {
if (char.codePointAt(0)! > 0xff) {
return false;
}
}
return true;
}
function truncateNtfyTitle(title: string): string {
const characters = Array.from(title);
if (characters.length <= NTFY_TITLE_MAX) {
return title;
}
return `${characters.slice(0, NTFY_TITLE_MAX - 1).join("")}`;
}
function truncateNtfyMessage(message: string): string {
if (Buffer.byteLength(message, "utf8") <= NTFY_MESSAGE_MAX) {
return message;
}
const characters = Array.from(message);
let low = 0;
let high = characters.length;
let best = "…";
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const candidate = `${characters.slice(0, mid).join("")}`;
if (Buffer.byteLength(candidate, "utf8") <= NTFY_MESSAGE_MAX) {
best = candidate;
low = mid + 1;
} else {
high = mid - 1;
}
}
return best;
}
export function resolveNtfyEvents(events?: NtfyNotificationEvent[]): NtfyNotificationEvent[] {
return events ? [...events] : [...DEFAULT_NTFY_EVENTS];
}
@@ -151,26 +195,40 @@ export async function sendNtfyNotificationWithResult({
signal,
}: SendNtfyNotificationInput): Promise<{ ok: boolean; status: number; statusText: string } | null> {
try {
const resolvedBaseUrl = resolveNtfyBaseUrl(ntfyBaseUrl);
const trimmedToken = ntfyAccessToken?.trim();
const truncatedTitle = truncateNtfyTitle(title);
const truncatedMessage = truncateNtfyMessage(message);
const latin1Safe = isLatin1Safe(truncatedTitle) && isLatin1Safe(truncatedMessage);
const headers: Record<string, string> = {
Title: title,
Priority: priority,
"Content-Type": "text/plain",
"Content-Type": latin1Safe ? "text/plain" : "application/json",
};
if (clickUrl) {
headers.Click = clickUrl;
if (latin1Safe) {
headers.Title = truncatedTitle;
if (clickUrl) {
headers.Click = clickUrl;
}
}
const trimmedToken = ntfyAccessToken?.trim();
if (trimmedToken) {
headers.Authorization = `Bearer ${trimmedToken}`;
}
const resolvedBaseUrl = resolveNtfyBaseUrl(ntfyBaseUrl);
const response = await fetch(`${resolvedBaseUrl}/${topic}`, {
const response = await fetch(latin1Safe ? `${resolvedBaseUrl}/${topic}` : `${resolvedBaseUrl}/`, {
method: "POST",
headers,
body: message,
body: latin1Safe
? truncatedMessage
: JSON.stringify({
topic,
title: truncatedTitle,
message: truncatedMessage,
priority,
...(clickUrl ? { click: clickUrl } : {}),
}),
signal,
});