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:
5
.changeset/FN-4127-ntfy-encoding.md
Normal file
5
.changeset/FN-4127-ntfy-encoding.md
Normal 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.
|
||||||
@@ -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. |
|
| `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** 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. |
|
| `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. |
|
||||||
|
|||||||
@@ -61,11 +61,14 @@ describe("message notification pipeline integration", () => {
|
|||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
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 firstOptions = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||||
const firstHeaders = firstOptions.headers as Record<string, string>;
|
const firstHeaders = firstOptions.headers as Record<string, string>;
|
||||||
expect(firstHeaders.Title).toBe("agent-A → agent-B");
|
expect(firstHeaders["Content-Type"]).toBe("application/json");
|
||||||
expect(String(firstOptions.body)).toContain("agent-A messaged agent-B: hi from agent A");
|
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({
|
messageStore.sendMessage({
|
||||||
fromId: "agent-A",
|
fromId: "agent-A",
|
||||||
@@ -80,8 +83,11 @@ describe("message notification pipeline integration", () => {
|
|||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
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(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"] });
|
await taskStore.updateGlobalSettings({ ntfyEvents: ["message:agent-to-user"] });
|
||||||
|
|
||||||
@@ -147,9 +153,13 @@ describe("message notification pipeline integration", () => {
|
|||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.sh/");
|
||||||
const request = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
const request = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||||
const headers = request.headers as Record<string, string>;
|
const headers = request.headers as Record<string, string>;
|
||||||
expect(headers.Title).toBe("New message from agent-A");
|
expect(headers["Content-Type"]).toBe("application/json");
|
||||||
expect(String(request.body)).toContain("agent-A → you: hello user");
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,22 +53,25 @@ describe("message notification pipeline", () => {
|
|||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
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 firstHeaders = (fetchSpy.mock.calls[0]?.[1] as RequestInit).headers as Record<string, string>;
|
const firstRequest = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||||
expect(firstHeaders.Title).toContain("Triage Bot");
|
const firstHeaders = firstRequest.headers as Record<string, string>;
|
||||||
|
expect(firstHeaders["Content-Type"]).toBe("application/json");
|
||||||
expect(firstHeaders.Authorization).toBe("Bearer token-123");
|
expect(firstHeaders.Authorization).toBe("Bearer token-123");
|
||||||
const firstBody = String((fetchSpy.mock.calls[0]?.[1] as RequestInit).body);
|
const firstBody = JSON.parse(String(firstRequest.body)) as { title: string; message: string; topic: string };
|
||||||
expect(firstBody).toContain("hi");
|
expect(firstBody.topic).toBe("test-topic");
|
||||||
|
expect(firstBody.title).toContain("Triage Bot");
|
||||||
|
expect(firstBody.message).toContain("hi");
|
||||||
|
|
||||||
messageStore.sendMessage("agent-to-agent", "relay");
|
messageStore.sendMessage("agent-to-agent", "relay");
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
const secondHeaders = (fetchSpy.mock.calls[1]?.[1] as RequestInit).headers as Record<string, string>;
|
expect(fetchSpy.mock.calls[1]?.[0]).toBe("https://ntfy.sh/");
|
||||||
expect(secondHeaders.Title).toContain("Triage Bot → Executor Bot");
|
const secondBody = JSON.parse(String((fetchSpy.mock.calls[1]?.[1] as RequestInit).body)) as { title: string; message: string };
|
||||||
const secondBody = String((fetchSpy.mock.calls[1]?.[1] as RequestInit).body);
|
expect(secondBody.title).toContain("Triage Bot → Executor Bot");
|
||||||
expect(secondBody).toContain("relay");
|
expect(secondBody.message).toContain("relay");
|
||||||
|
|
||||||
messageStore.sendMessage("user-to-agent", "ignore me");
|
messageStore.sendMessage("user-to-agent", "ignore me");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
isNtfyEventEnabled,
|
isNtfyEventEnabled,
|
||||||
resolveNtfyEvents,
|
resolveNtfyEvents,
|
||||||
notifyFallbackUsed,
|
notifyFallbackUsed,
|
||||||
|
sendNtfyNotificationWithResult,
|
||||||
} from "../notifier.js";
|
} from "../notifier.js";
|
||||||
import { NotificationService } from "../notification/notification-service.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", () => {
|
describe("NtfyNotifier", () => {
|
||||||
let store: MockTaskStore;
|
let store: MockTaskStore;
|
||||||
let notifier: NtfyNotifier;
|
let notifier: NtfyNotifier;
|
||||||
|
|||||||
@@ -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 () => {
|
it("uses Re: title for agent-to-agent replies", async () => {
|
||||||
await provider.sendNotification("message:agent-to-agent" as any, {
|
await provider.sendNotification("message:agent-to-agent" as any, {
|
||||||
event: "message:agent-to-agent" as any,
|
event: "message:agent-to-agent" as any,
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export type NtfyNotificationPriority = "low" | "default" | "high" | "urgent";
|
|||||||
|
|
||||||
const DEFAULT_NTFY_BASE_URL = "https://ntfy.sh";
|
const DEFAULT_NTFY_BASE_URL = "https://ntfy.sh";
|
||||||
const GRIDLOCK_NOTIFICATION_COOLDOWN_MS = 15 * 60 * 1000;
|
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[] = [
|
export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
|
||||||
"in-review",
|
"in-review",
|
||||||
@@ -93,6 +96,47 @@ function resolveNtfyBaseUrl(baseUrl: string | undefined, fallback = DEFAULT_NTFY
|
|||||||
return trimmed.replace(/\/+$/, "");
|
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[] {
|
export function resolveNtfyEvents(events?: NtfyNotificationEvent[]): NtfyNotificationEvent[] {
|
||||||
return events ? [...events] : [...DEFAULT_NTFY_EVENTS];
|
return events ? [...events] : [...DEFAULT_NTFY_EVENTS];
|
||||||
}
|
}
|
||||||
@@ -151,26 +195,40 @@ export async function sendNtfyNotificationWithResult({
|
|||||||
signal,
|
signal,
|
||||||
}: SendNtfyNotificationInput): Promise<{ ok: boolean; status: number; statusText: string } | null> {
|
}: SendNtfyNotificationInput): Promise<{ ok: boolean; status: number; statusText: string } | null> {
|
||||||
try {
|
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> = {
|
const headers: Record<string, string> = {
|
||||||
Title: title,
|
|
||||||
Priority: priority,
|
Priority: priority,
|
||||||
"Content-Type": "text/plain",
|
"Content-Type": latin1Safe ? "text/plain" : "application/json",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (clickUrl) {
|
if (latin1Safe) {
|
||||||
headers.Click = clickUrl;
|
headers.Title = truncatedTitle;
|
||||||
|
if (clickUrl) {
|
||||||
|
headers.Click = clickUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedToken = ntfyAccessToken?.trim();
|
|
||||||
if (trimmedToken) {
|
if (trimmedToken) {
|
||||||
headers.Authorization = `Bearer ${trimmedToken}`;
|
headers.Authorization = `Bearer ${trimmedToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedBaseUrl = resolveNtfyBaseUrl(ntfyBaseUrl);
|
const response = await fetch(latin1Safe ? `${resolvedBaseUrl}/${topic}` : `${resolvedBaseUrl}/`, {
|
||||||
const response = await fetch(`${resolvedBaseUrl}/${topic}`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: message,
|
body: latin1Safe
|
||||||
|
? truncatedMessage
|
||||||
|
: JSON.stringify({
|
||||||
|
topic,
|
||||||
|
title: truncatedTitle,
|
||||||
|
message: truncatedMessage,
|
||||||
|
priority,
|
||||||
|
...(clickUrl ? { click: clickUrl } : {}),
|
||||||
|
}),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user