Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de31ac7eb7 |
@@ -0,0 +1,175 @@
|
||||
import type { Language } from "../../i18n/translations.js";
|
||||
|
||||
export type PushNotificationAction =
|
||||
| {
|
||||
kind: "taken";
|
||||
label: string;
|
||||
url: string;
|
||||
method: "POST";
|
||||
}
|
||||
| {
|
||||
kind: "skip";
|
||||
label: string;
|
||||
url: string;
|
||||
method: "POST";
|
||||
}
|
||||
| {
|
||||
kind: "view";
|
||||
label: string;
|
||||
url: string;
|
||||
method: "GET";
|
||||
};
|
||||
|
||||
export type PushNotificationOptions = {
|
||||
actions?: PushNotificationAction[];
|
||||
respondUrl?: string;
|
||||
viewUrl?: string;
|
||||
clickUrl?: string;
|
||||
tags?: string[];
|
||||
priority?: number;
|
||||
sequenceId?: string;
|
||||
};
|
||||
|
||||
type NtfyActionPayload = {
|
||||
action: "http" | "view";
|
||||
label: string;
|
||||
url: string;
|
||||
method?: "POST";
|
||||
clear: boolean;
|
||||
};
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
if ([...value].every((char) => char.charCodeAt(0) <= 0x7f)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `=?UTF-8?B?${Buffer.from(value, "utf-8").toString("base64")}?=`;
|
||||
}
|
||||
|
||||
export function isNtfyNotificationUrl(urlStr: string): boolean {
|
||||
if (urlStr.startsWith("ntfy://")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(urlStr);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
return hostname === "ntfy.sh" || hostname === "ntfy" || hostname.startsWith("ntfy.") || hostname.includes(".ntfy.");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNotificationProvider(urlStr: string): string {
|
||||
if (isNtfyNotificationUrl(urlStr)) {
|
||||
return "ntfy";
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(urlStr).protocol.replace(":", "").toLowerCase();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function getNotificationActionLabels(language: Language): {
|
||||
taken: string;
|
||||
skip: string;
|
||||
respond: string;
|
||||
view: string;
|
||||
} {
|
||||
if (language === "de") {
|
||||
return {
|
||||
taken: "Einnehmen",
|
||||
skip: "Überspringen",
|
||||
respond: "Antworten",
|
||||
view: "Öffnen",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
taken: "Take",
|
||||
skip: "Skip",
|
||||
respond: "Respond",
|
||||
view: "View",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNtfyActions(options: PushNotificationOptions): NtfyActionPayload[] {
|
||||
const actions = options.actions ?? [];
|
||||
|
||||
return actions.map((action) => {
|
||||
if (action.kind === "view") {
|
||||
return {
|
||||
action: "view",
|
||||
label: action.label,
|
||||
url: action.url,
|
||||
clear: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: "http",
|
||||
label: action.label,
|
||||
url: action.url,
|
||||
method: "POST",
|
||||
// Clear the original actionable ntfy notification locally after a successful mutation.
|
||||
clear: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function appendFallbackActionLinks(message: string, options: PushNotificationOptions): string {
|
||||
if (!options.respondUrl && !options.viewUrl) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const lines = [message.trimEnd()];
|
||||
|
||||
if (options.respondUrl) {
|
||||
lines.push("", "Respond:", options.respondUrl);
|
||||
}
|
||||
|
||||
if (options.viewUrl) {
|
||||
lines.push("", "View:", options.viewUrl);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function renderNotificationActionPayload(
|
||||
urlStr: string,
|
||||
message: string,
|
||||
options: PushNotificationOptions
|
||||
): { message: string; headers: Record<string, string> } {
|
||||
if (!isNtfyNotificationUrl(urlStr)) {
|
||||
return {
|
||||
message: appendFallbackActionLinks(message, options),
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const ntfyActions = buildNtfyActions(options);
|
||||
if (ntfyActions.length > 0) {
|
||||
headers.Actions = encodeHeaderValue(JSON.stringify(ntfyActions));
|
||||
}
|
||||
if (options.clickUrl && ntfyActions.length === 0) {
|
||||
headers.Click = options.clickUrl;
|
||||
}
|
||||
if (options.tags && options.tags.length > 0) {
|
||||
headers.Tags = options.tags.join(",");
|
||||
}
|
||||
if (typeof options.priority === "number") {
|
||||
headers.Priority = String(options.priority);
|
||||
}
|
||||
if (options.sequenceId) {
|
||||
headers["X-Sequence-ID"] = options.sequenceId;
|
||||
}
|
||||
|
||||
return { message, headers };
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getNotificationActionLabels,
|
||||
isNtfyNotificationUrl,
|
||||
type PushNotificationAction,
|
||||
renderNotificationActionPayload,
|
||||
} from "../services/notifications/action-renderer.js";
|
||||
|
||||
function decodeRfc2047Base64(value: string): string {
|
||||
const match = /^=\?UTF-8\?B\?(.+)\?=$/.exec(value);
|
||||
if (!match) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Buffer.from(match[1], "base64").toString("utf-8");
|
||||
}
|
||||
|
||||
const actions: PushNotificationAction[] = [
|
||||
{
|
||||
kind: "taken",
|
||||
label: "Take",
|
||||
url: "https://app.example.com/api/notification-actions/taken-token",
|
||||
method: "POST",
|
||||
},
|
||||
{
|
||||
kind: "skip",
|
||||
label: "Skip",
|
||||
url: "https://app.example.com/api/notification-actions/skip-token",
|
||||
method: "POST",
|
||||
},
|
||||
{ kind: "view", label: "View", url: "https://app.example.com/?date=2026-01-05", method: "GET" },
|
||||
];
|
||||
|
||||
describe("notification action renderer", () => {
|
||||
it("builds ntfy native actions without duplicate click headers", () => {
|
||||
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
|
||||
actions,
|
||||
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||
tags: ["pill"],
|
||||
priority: 4,
|
||||
sequenceId: "medassist-sequence",
|
||||
});
|
||||
|
||||
expect(result.message).toBe("Body");
|
||||
expect(result.headers).toMatchObject({
|
||||
Tags: "pill",
|
||||
Priority: "4",
|
||||
"X-Sequence-ID": "medassist-sequence",
|
||||
});
|
||||
expect(result.headers.Click).toBeUndefined();
|
||||
|
||||
const parsedActions = JSON.parse(result.headers.Actions ?? "[]");
|
||||
expect(parsedActions).toEqual([
|
||||
{
|
||||
action: "http",
|
||||
label: "Take",
|
||||
url: "https://app.example.com/api/notification-actions/taken-token",
|
||||
method: "POST",
|
||||
clear: true,
|
||||
},
|
||||
{
|
||||
action: "http",
|
||||
label: "Skip",
|
||||
url: "https://app.example.com/api/notification-actions/skip-token",
|
||||
method: "POST",
|
||||
clear: true,
|
||||
},
|
||||
{
|
||||
action: "view",
|
||||
label: "View",
|
||||
url: "https://app.example.com/?date=2026-01-05",
|
||||
clear: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the ntfy click header when there are no native actions", () => {
|
||||
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
|
||||
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||
});
|
||||
|
||||
expect(result.headers.Click).toBe("https://app.example.com/api/notification-actions/respond-token");
|
||||
});
|
||||
|
||||
it("treats direct https ntfy URLs as ntfy targets with native actions", () => {
|
||||
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
|
||||
actions,
|
||||
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||
});
|
||||
|
||||
expect(isNtfyNotificationUrl("https://ntfy.danielvolz.org/medis_test")).toBe(true);
|
||||
expect(result.message).toBe("Body");
|
||||
expect(result.headers.Actions).toBeTruthy();
|
||||
expect(result.message).not.toContain("Respond:");
|
||||
});
|
||||
|
||||
it("keeps insecure http mutation targets as direct ntfy http actions without the dev fallback", () => {
|
||||
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
|
||||
actions: [
|
||||
{
|
||||
kind: "taken",
|
||||
label: "Take",
|
||||
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(JSON.parse(result.headers.Actions ?? "[]")).toEqual([
|
||||
{
|
||||
action: "http",
|
||||
label: "Take",
|
||||
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
|
||||
method: "POST",
|
||||
clear: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("encodes non-ascii ntfy action labels as RFC 2047 headers", () => {
|
||||
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
|
||||
actions: [
|
||||
{
|
||||
kind: "skip",
|
||||
label: "Überspringen",
|
||||
url: "https://app.example.com/api/notification-actions/skip-token",
|
||||
method: "POST",
|
||||
},
|
||||
{
|
||||
kind: "view",
|
||||
label: "Öffnen",
|
||||
url: "https://app.example.com/?date=2026-01-05",
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.headers.Actions).toMatch(/^=\?UTF-8\?B\?/);
|
||||
expect(JSON.parse(decodeRfc2047Base64(result.headers.Actions ?? "[]"))).toEqual([
|
||||
{
|
||||
action: "http",
|
||||
label: "Überspringen",
|
||||
url: "https://app.example.com/api/notification-actions/skip-token",
|
||||
method: "POST",
|
||||
clear: true,
|
||||
},
|
||||
{
|
||||
action: "view",
|
||||
label: "Öffnen",
|
||||
url: "https://app.example.com/?date=2026-01-05",
|
||||
clear: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses consistent action-form labels for English and German", () => {
|
||||
expect(getNotificationActionLabels("en")).toEqual({
|
||||
taken: "Take",
|
||||
skip: "Skip",
|
||||
respond: "Respond",
|
||||
view: "View",
|
||||
});
|
||||
expect(getNotificationActionLabels("de")).toEqual({
|
||||
taken: "Einnehmen",
|
||||
skip: "Überspringen",
|
||||
respond: "Antworten",
|
||||
view: "Öffnen",
|
||||
});
|
||||
});
|
||||
|
||||
it("appends respond and view fallback links for non-ntfy providers", () => {
|
||||
const result = renderNotificationActionPayload("https://hooks.slack.com/services/a/b/c", "Body", {
|
||||
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
|
||||
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||
});
|
||||
|
||||
expect(result.headers).toEqual({});
|
||||
expect(result.message).toBe(
|
||||
"Body\n\nRespond:\nhttps://app.example.com/api/notification-actions/respond-token\n\nView:\nhttps://app.example.com/?date=2026-01-05"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user