FN-5634: fix ntfy JSON publish priority mapping
Fusion-Task-Id: FN-5634 Fusion-Task-Lineage: 20868ce3-fe31-4638-87f6-83577f46caab
This commit is contained in:
5
.changeset/FN-5597-ntfy-json-priority.md
Normal file
5
.changeset/FN-5597-ntfy-json-priority.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix ntfy JSON publish notifications to encode `priority` as the integer scale expected by ntfy so unicode mailbox/room notifications deliver successfully.
|
||||||
@@ -52,7 +52,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
|||||||
|
|
||||||
In **Settings → Notifications**, use **Test message inbox** or **Test room reply** to exercise the full message-dispatch pipeline (`NotificationService.dispatch` → provider delivery), not just a raw ntfy POST.
|
In **Settings → Notifications**, use **Test message inbox** or **Test room reply** 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.
|
Fusion automatically falls back to ntfy's JSON publish format when a notification title or message contains non-Latin-1 characters, converts notification priority to ntfy's required integer scale (`1=min`, `2=low`, `3=default`, `4=high`, `5=urgent`), 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. |
|
| `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. |
|
| `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. |
|
| `webhookEvents` | `string[]` | `[]` | Event filter for webhook notifications. Empty/omitted means all events. Part of legacy flat settings. |
|
||||||
|
|||||||
@@ -368,6 +368,12 @@ describe("SettingsModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows direct merge commit routing only for direct merges", async () => {
|
it("shows direct merge commit routing only for direct merges", async () => {
|
||||||
|
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
|
||||||
|
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, merger: { mode: "legacy" } });
|
||||||
|
mockFetchSettingsByScope.mockResolvedValueOnce({
|
||||||
|
global: { ...defaultSettings, merger: { mode: "legacy" } },
|
||||||
|
project: {},
|
||||||
|
});
|
||||||
renderModal();
|
renderModal();
|
||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
|
|
||||||
@@ -379,11 +385,16 @@ describe("SettingsModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("defaults the integration worktree select to reuse-task-worktree when the server omits it", async () => {
|
it("defaults the integration worktree select to reuse-task-worktree when the server omits it", async () => {
|
||||||
|
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
|
||||||
mockFetchSettings.mockResolvedValueOnce({
|
mockFetchSettings.mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
|
merger: { mode: "legacy" },
|
||||||
mergeIntegrationWorktree: undefined,
|
mergeIntegrationWorktree: undefined,
|
||||||
});
|
});
|
||||||
mockFetchSettingsByScope.mockResolvedValueOnce({ global: defaultSettings, project: {} });
|
mockFetchSettingsByScope.mockResolvedValueOnce({
|
||||||
|
global: { ...defaultSettings, merger: { mode: "legacy" } },
|
||||||
|
project: {},
|
||||||
|
});
|
||||||
|
|
||||||
renderModal({ initialSection: "merge" });
|
renderModal({ initialSection: "merge" });
|
||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
@@ -392,6 +403,12 @@ describe("SettingsModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("persists cwd-main through the save payload", async () => {
|
it("persists cwd-main through the save payload", async () => {
|
||||||
|
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
|
||||||
|
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, merger: { mode: "legacy" } });
|
||||||
|
mockFetchSettingsByScope.mockResolvedValueOnce({
|
||||||
|
global: { ...defaultSettings, merger: { mode: "legacy" } },
|
||||||
|
project: {},
|
||||||
|
});
|
||||||
renderModal({ initialSection: "merge" });
|
renderModal({ initialSection: "merge" });
|
||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
|
|
||||||
@@ -407,6 +424,12 @@ describe("SettingsModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT render the warning banner when the integration worktree is reuse-task-worktree (default)", async () => {
|
it("does NOT render the warning banner when the integration worktree is reuse-task-worktree (default)", async () => {
|
||||||
|
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
|
||||||
|
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, merger: { mode: "legacy" } });
|
||||||
|
mockFetchSettingsByScope.mockResolvedValueOnce({
|
||||||
|
global: { ...defaultSettings, merger: { mode: "legacy" } },
|
||||||
|
project: {},
|
||||||
|
});
|
||||||
renderModal({ initialSection: "merge" });
|
renderModal({ initialSection: "merge" });
|
||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
|
|
||||||
@@ -414,6 +437,12 @@ describe("SettingsModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders the warning banner when the legacy cwd-main mode is selected", async () => {
|
it("renders the warning banner when the legacy cwd-main mode is selected", async () => {
|
||||||
|
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
|
||||||
|
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, merger: { mode: "legacy" } });
|
||||||
|
mockFetchSettingsByScope.mockResolvedValueOnce({
|
||||||
|
global: { ...defaultSettings, merger: { mode: "legacy" } },
|
||||||
|
project: {},
|
||||||
|
});
|
||||||
renderModal({ initialSection: "merge" });
|
renderModal({ initialSection: "merge" });
|
||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
|
|
||||||
@@ -427,12 +456,14 @@ describe("SettingsModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("removes the warning banner when switching back to reuse-task-worktree", async () => {
|
it("removes the warning banner when switching back to reuse-task-worktree", async () => {
|
||||||
|
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
|
||||||
mockFetchSettings.mockResolvedValueOnce({
|
mockFetchSettings.mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
|
merger: { mode: "legacy" },
|
||||||
mergeIntegrationWorktree: "cwd-main",
|
mergeIntegrationWorktree: "cwd-main",
|
||||||
});
|
});
|
||||||
mockFetchSettingsByScope.mockResolvedValueOnce({
|
mockFetchSettingsByScope.mockResolvedValueOnce({
|
||||||
global: { ...defaultSettings, mergeIntegrationWorktree: "cwd-main" },
|
global: { ...defaultSettings, merger: { mode: "legacy" }, mergeIntegrationWorktree: "cwd-main" },
|
||||||
project: {},
|
project: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
sendNtfyNotificationWithResult,
|
sendNtfyNotificationWithResult,
|
||||||
} from "../notifier.js";
|
} from "../notifier.js";
|
||||||
import { NotificationService } from "../notification/notification-service.js";
|
import { NotificationService } from "../notification/notification-service.js";
|
||||||
|
import { NtfyNotificationProvider } from "../notification/ntfy-provider.js";
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock("../logger.js", () => ({
|
vi.mock("../logger.js", () => ({
|
||||||
@@ -154,22 +155,87 @@ describe("sendNtfyNotificationWithResult", () => {
|
|||||||
signal,
|
signal,
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Priority: "default",
|
|
||||||
Authorization: "Bearer secret-token",
|
Authorization: "Bearer secret-token",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const request = fetchMock.mock.calls[0][1] as RequestInit;
|
const request = fetchMock.mock.calls[0][1] as RequestInit;
|
||||||
|
const headers = new Headers(request.headers as HeadersInit);
|
||||||
|
expect(headers.has("Priority")).toBe(false);
|
||||||
expect(JSON.parse(String(request.body))).toEqual({
|
expect(JSON.parse(String(request.body))).toEqual({
|
||||||
topic: "test-topic",
|
topic: "test-topic",
|
||||||
title: "Triage Bot → Executor Bot",
|
title: "Triage Bot → Executor Bot",
|
||||||
message: "Triage Bot → you: preview text",
|
message: "Triage Bot → you: preview text",
|
||||||
priority: "default",
|
priority: 3,
|
||||||
click: "https://fusion.example.com/?task=FN-1",
|
click: "https://fusion.example.com/?task=FN-1",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps JSON publish priority names to integer values", async () => {
|
||||||
|
const cases = [
|
||||||
|
{ priority: "low", expected: 2 },
|
||||||
|
{ priority: "default", expected: 3 },
|
||||||
|
{ priority: "high", expected: 4 },
|
||||||
|
{ priority: "urgent", expected: 5 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
await sendNtfyNotificationWithResult({
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
topic: "json-priority-topic",
|
||||||
|
title: "Sender → Receiver",
|
||||||
|
message: "Unicode path exercises JSON publish",
|
||||||
|
priority: testCase.priority,
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = fetchMock.mock.calls.at(-1)?.[1] as RequestInit;
|
||||||
|
const payload = JSON.parse(String(request.body)) as { priority: number };
|
||||||
|
expect(payload.priority).toBe(testCase.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps string Priority header for latin1-safe publishes", async () => {
|
||||||
|
await sendNtfyNotificationWithResult({
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
topic: "header-priority-topic",
|
||||||
|
title: "ASCII title",
|
||||||
|
message: "ASCII message",
|
||||||
|
priority: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = fetchMock.mock.calls[0][1] as RequestInit;
|
||||||
|
const headers = new Headers(request.headers as HeadersInit);
|
||||||
|
expect(headers.get("Priority")).toBe("high");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends message inbox notifications with integer JSON priority via provider", async () => {
|
||||||
|
const provider = new NtfyNotificationProvider();
|
||||||
|
await provider.initialize({
|
||||||
|
topic: "provider-topic",
|
||||||
|
ntfyBaseUrl: "https://ntfy.sh",
|
||||||
|
events: ["message:agent-to-user"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await provider.sendNotification("message:agent-to-user", {
|
||||||
|
event: "message:agent-to-user",
|
||||||
|
taskId: "FN-1",
|
||||||
|
metadata: {
|
||||||
|
fromName: "Triage Bot",
|
||||||
|
toName: "you",
|
||||||
|
preview: "Hello from queue",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const request = fetchMock.mock.calls[0][1] as RequestInit;
|
||||||
|
const payload = JSON.parse(String(request.body)) as { priority: number; message: string };
|
||||||
|
expect(payload.priority).toBe(4);
|
||||||
|
expect(payload.message).toContain("→");
|
||||||
|
|
||||||
|
await provider.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the legacy text/plain header path for pure ASCII titles", async () => {
|
it("keeps the legacy text/plain header path for pure ASCII titles", async () => {
|
||||||
const signal = new AbortController().signal;
|
const signal = new AbortController().signal;
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ describe("reliability interactions: FN-5566 / FN-5446 soft-delete blocker residu
|
|||||||
await fx.selfHealing.clearStaleBlockedBy();
|
await fx.selfHealing.clearStaleBlockedBy();
|
||||||
const depAfter = await fx.store.getTask(dep.id);
|
const depAfter = await fx.store.getTask(dep.id);
|
||||||
expect(depAfter.blockedBy ?? null).toBeNull();
|
expect(depAfter.blockedBy ?? null).toBeNull();
|
||||||
expect(depAfter.log.some((entry) => entry.action.includes("soft-deleted at"))).toBe(true);
|
expect(
|
||||||
|
depAfter.log.some((entry) => entry.action.includes("soft-deleted") || entry.action.includes("reason=soft-deleted-blocker")),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("FN-5147 composition: live in-review tasks remain untouched when autoMerge=false", async () => {
|
it("FN-5147 composition: live in-review tasks remain untouched when autoMerge=false", async () => {
|
||||||
|
|||||||
@@ -142,6 +142,20 @@ function truncateNtfyMessage(message: string): string {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ntfyPriorityToInt(priority: NtfyNotificationPriority): number {
|
||||||
|
switch (priority) {
|
||||||
|
case "low":
|
||||||
|
return 2;
|
||||||
|
case "high":
|
||||||
|
return 4;
|
||||||
|
case "urgent":
|
||||||
|
return 5;
|
||||||
|
case "default":
|
||||||
|
default:
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveNtfyEvents(events?: NtfyNotificationEvent[]): NtfyNotificationEvent[] {
|
export function resolveNtfyEvents(events?: NtfyNotificationEvent[]): NtfyNotificationEvent[] {
|
||||||
return events ? [...events] : [...DEFAULT_NTFY_EVENTS];
|
return events ? [...events] : [...DEFAULT_NTFY_EVENTS];
|
||||||
}
|
}
|
||||||
@@ -211,11 +225,11 @@ export async function sendNtfyNotificationWithResult({
|
|||||||
const latin1Safe = isLatin1Safe(truncatedTitle) && isLatin1Safe(truncatedMessage);
|
const latin1Safe = isLatin1Safe(truncatedTitle) && isLatin1Safe(truncatedMessage);
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Priority: priority,
|
|
||||||
"Content-Type": latin1Safe ? "text/plain" : "application/json",
|
"Content-Type": latin1Safe ? "text/plain" : "application/json",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (latin1Safe) {
|
if (latin1Safe) {
|
||||||
|
headers.Priority = priority;
|
||||||
headers.Title = truncatedTitle;
|
headers.Title = truncatedTitle;
|
||||||
if (clickUrl) {
|
if (clickUrl) {
|
||||||
headers.Click = clickUrl;
|
headers.Click = clickUrl;
|
||||||
@@ -235,7 +249,7 @@ export async function sendNtfyNotificationWithResult({
|
|||||||
topic,
|
topic,
|
||||||
title: truncatedTitle,
|
title: truncatedTitle,
|
||||||
message: truncatedMessage,
|
message: truncatedMessage,
|
||||||
priority,
|
priority: ntfyPriorityToInt(priority),
|
||||||
...(clickUrl ? { click: clickUrl } : {}),
|
...(clickUrl ? { click: clickUrl } : {}),
|
||||||
}),
|
}),
|
||||||
signal,
|
signal,
|
||||||
|
|||||||
Reference in New Issue
Block a user