mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(notifications): send content and text so Discord and Slack webhooks deliver
The generic webhook body carried title, message, eventType and priority only. Discord refuses a body without content (400, code 50006 "Cannot send an empty message") and a Slack incoming webhook one without text, so the test button and every reminder failed against the targets the settings card names. The generic body now appends content (cut to Discord's 2000 characters) and text, both the masked title and message on two lines, plus allowed_mentions with an empty parse list so Discord resolves no mentions. The four original fields keep their values and order for Home Assistant and n8n rules. The Gotify body is unchanged. The card names what works and shows a hint for the generic format.
This commit is contained in:
1 parent
d8a3614120
commit
d029e4e1dc
16 files changed
+336
-32
No files matched your search
@@ -155,6 +155,14 @@ an earlier fold computed from part of a day.
|
||||
- **Push after an update.** Reload on the new-version hint unregistered the
|
||||
service worker, which ended the Web Push subscription; it now updates the
|
||||
worker instead.
|
||||
- **Discord and Slack webhooks.** A Discord webhook URL answered the test
|
||||
and every reminder with "Cannot send an empty message", and a Slack
|
||||
incoming webhook refused the body too: the generic format carried neither
|
||||
Discord's `content` nor Slack's `text`. It now sends both as plain text
|
||||
(title and message, cut to Discord's 2000 characters), tells Discord to
|
||||
resolve no mentions, and keeps `title`, `message`, `eventType` and
|
||||
`priority` unchanged for Home Assistant and n8n rules. Discreet mode
|
||||
covers the new fields.
|
||||
- **MCP deep links.** `/insights?metric=` and `/labs?analyte=` open the
|
||||
metric's page and the marker's page.
|
||||
- **Apple Health status** no longer reads "waiting for first data" after an
|
||||
|
||||
@@ -29670,9 +29670,10 @@ components:
|
||||
- type: string
|
||||
const: ""
|
||||
format:
|
||||
description: Body shape. `generic` sends HealthLog's own JSON envelope; `gotify` sends the body Gotify's `POST /message`
|
||||
expects, with an integer priority. Omitted keeps the stored choice, which is `generic` for a config saved
|
||||
before the choice existed.
|
||||
description: Body shape. `generic` sends HealthLog's own JSON envelope (`title`, `message`, `eventType`, `priority`,
|
||||
plus `content` and `text` so a Discord or Slack webhook URL accepts it as is); `gotify` sends the body
|
||||
Gotify's `POST /message` expects, with an integer priority. Omitted keeps the stored choice, which is
|
||||
`generic` for a config saved before the choice existed.
|
||||
type: string
|
||||
enum:
|
||||
- generic
|
||||
|
||||
+2
-1
@@ -6017,7 +6017,7 @@
|
||||
"ntfyAuthToken": "Auth-Token (optional)",
|
||||
"ntfyAuthTokenHint": "Nur nötig bei privaten Topics mit Zugangsschutz.",
|
||||
"webhook": "Webhook",
|
||||
"webhookDescription": "Benachrichtigungen per POST an eine beliebige URL — Gotify, Discord, Slack, Matrix oder Home Assistant.",
|
||||
"webhookDescription": "Benachrichtigungen per POST an eine URL: Discord, Slack, Gotify, Home Assistant, n8n oder jedes Relay, das JSON annimmt.",
|
||||
"webhookEnable": "Webhook aktivieren",
|
||||
"webhookUrl": "Webhook-URL",
|
||||
"webhookUrlHint": "Die vollständige URL für den POST. Eine Adresse im privaten Netz muss der Betreiber des Servers freigeben.",
|
||||
@@ -6025,6 +6025,7 @@
|
||||
"webhookHeaderValue": "Header-Wert (optional)",
|
||||
"webhookFormat": "Nachrichtenformat",
|
||||
"webhookFormatGeneric": "Generisches JSON",
|
||||
"webhookFormatGenericHint": "Eine Discord- oder Slack-Webhook-URL einfach so einfügen. Home Assistant und n8n lesen die Felder title, message, eventType und priority.",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "Nutze die URL, die auf /message endet, und trage das App-Token als Header X-Gotify-Key ein.",
|
||||
"email": "E-Mail",
|
||||
|
||||
+2
-1
@@ -6017,7 +6017,7 @@
|
||||
"ntfyAuthToken": "Auth Token (optional)",
|
||||
"ntfyAuthTokenHint": "Only needed for private topics with access control.",
|
||||
"webhook": "Webhook",
|
||||
"webhookDescription": "POST notifications to any URL — Gotify, Discord, Slack, Matrix, or Home Assistant.",
|
||||
"webhookDescription": "POST notifications to any URL: Discord, Slack, Gotify, Home Assistant, n8n, or any relay that takes JSON.",
|
||||
"webhookEnable": "Enable webhook",
|
||||
"webhookUrl": "Webhook URL",
|
||||
"webhookUrlHint": "The full URL to POST to. An address on a private network needs approval from the server operator.",
|
||||
@@ -6025,6 +6025,7 @@
|
||||
"webhookHeaderValue": "Header value (optional)",
|
||||
"webhookFormat": "Payload format",
|
||||
"webhookFormatGeneric": "Generic JSON",
|
||||
"webhookFormatGenericHint": "Paste a Discord or Slack webhook URL as it is. Home Assistant and n8n read the fields title, message, eventType and priority.",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "Use the URL ending in /message and put the application token in the header X-Gotify-Key.",
|
||||
"email": "Email",
|
||||
|
||||
+2
-1
@@ -6017,7 +6017,7 @@
|
||||
"ntfyAuthToken": "Token de autenticación (opcional)",
|
||||
"ntfyAuthTokenHint": "Solo necesario para temas privados con control de acceso.",
|
||||
"webhook": "Webhook",
|
||||
"webhookDescription": "Envía notificaciones por POST a cualquier URL: Gotify, Discord, Slack, Matrix o Home Assistant.",
|
||||
"webhookDescription": "Envía notificaciones por POST a una URL: Discord, Slack, Gotify, Home Assistant, n8n o un relé que acepte JSON.",
|
||||
"webhookEnable": "Activar webhook",
|
||||
"webhookUrl": "URL del webhook",
|
||||
"webhookUrlHint": "La URL completa para el POST. Una dirección de una red privada necesita la aprobación del administrador del servidor.",
|
||||
@@ -6025,6 +6025,7 @@
|
||||
"webhookHeaderValue": "Valor de cabecera (opcional)",
|
||||
"webhookFormat": "Formato del mensaje",
|
||||
"webhookFormatGeneric": "JSON genérico",
|
||||
"webhookFormatGenericHint": "Pega una URL de webhook de Discord o Slack tal cual. Home Assistant y n8n leen los campos title, message, eventType y priority.",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "Usa la URL que termina en /message y pon el token de la aplicación en la cabecera X-Gotify-Key.",
|
||||
"email": "Correo electrónico",
|
||||
|
||||
+2
-1
@@ -6017,7 +6017,7 @@
|
||||
"ntfyAuthToken": "Jeton d’authentification (facultatif)",
|
||||
"ntfyAuthTokenHint": "Nécessaire uniquement pour les sujets privés avec contrôle d’accès.",
|
||||
"webhook": "Webhook",
|
||||
"webhookDescription": "Envoyer les notifications par POST vers n’importe quelle URL — Gotify, Discord, Slack, Matrix ou Home Assistant.",
|
||||
"webhookDescription": "Notifications par POST vers une URL : Discord, Slack, Gotify, Home Assistant, n8n ou tout relais acceptant du JSON.",
|
||||
"webhookEnable": "Activer le webhook",
|
||||
"webhookUrl": "URL du webhook",
|
||||
"webhookUrlHint": "L’URL complète pour le POST. Une adresse sur un réseau privé doit être autorisée par l’administrateur du serveur.",
|
||||
@@ -6025,6 +6025,7 @@
|
||||
"webhookHeaderValue": "Valeur d’en-tête (facultatif)",
|
||||
"webhookFormat": "Format du message",
|
||||
"webhookFormatGeneric": "JSON générique",
|
||||
"webhookFormatGenericHint": "Collez telle quelle une URL de webhook Discord ou Slack. Home Assistant et n8n lisent les champs title, message, eventType et priority.",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "Utilisez l’URL se terminant par /message et placez le jeton de l’application dans l’en-tête X-Gotify-Key.",
|
||||
"email": "E-mail",
|
||||
|
||||
+2
-1
@@ -6017,7 +6017,7 @@
|
||||
"ntfyAuthToken": "Token di autenticazione (facoltativo)",
|
||||
"ntfyAuthTokenHint": "Necessario solo per topic privati con controllo degli accessi.",
|
||||
"webhook": "Webhook",
|
||||
"webhookDescription": "Invia le notifiche via POST a qualsiasi URL — Gotify, Discord, Slack, Matrix o Home Assistant.",
|
||||
"webhookDescription": "Invia le notifiche via POST a un URL: Discord, Slack, Gotify, Home Assistant, n8n o un relay che accetti JSON.",
|
||||
"webhookEnable": "Attiva webhook",
|
||||
"webhookUrl": "URL del webhook",
|
||||
"webhookUrlHint": "L’URL completo per il POST. Un indirizzo su una rete privata deve essere autorizzato dall’amministratore del server.",
|
||||
@@ -6025,6 +6025,7 @@
|
||||
"webhookHeaderValue": "Valore intestazione (opzionale)",
|
||||
"webhookFormat": "Formato del messaggio",
|
||||
"webhookFormatGeneric": "JSON generico",
|
||||
"webhookFormatGenericHint": "Incolla così com’è un URL webhook di Discord o Slack. Home Assistant e n8n leggono i campi title, message, eventType e priority.",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "Usa l’URL che termina con /message e inserisci il token dell’applicazione nell’intestazione X-Gotify-Key.",
|
||||
"email": "Email",
|
||||
|
||||
+2
-1
@@ -6017,7 +6017,7 @@
|
||||
"ntfyAuthToken": "인증 토큰 (선택)",
|
||||
"ntfyAuthTokenHint": "접근 제어가 걸린 비공개 토픽에만 필요해요.",
|
||||
"webhook": "웹훅",
|
||||
"webhookDescription": "Gotify, Discord, Slack, Matrix, Home Assistant 등 어떤 URL로든 알림을 POST해요.",
|
||||
"webhookDescription": "Discord, Slack, Gotify, Home Assistant, n8n 등 JSON을 받는 어떤 URL로든 알림을 POST해요.",
|
||||
"webhookEnable": "웹훅 켜기",
|
||||
"webhookUrl": "웹훅 URL",
|
||||
"webhookUrlHint": "POST를 보낼 전체 URL이에요. 사설 네트워크 주소는 서버 운영자가 허용해야 해요.",
|
||||
@@ -6025,6 +6025,7 @@
|
||||
"webhookHeaderValue": "헤더 값 (선택)",
|
||||
"webhookFormat": "메시지 형식",
|
||||
"webhookFormatGeneric": "일반 JSON",
|
||||
"webhookFormatGenericHint": "Discord나 Slack 웹후크 URL은 그대로 붙여 넣으면 돼요. Home Assistant와 n8n은 title, message, eventType, priority 필드를 읽어요.",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "/message로 끝나는 URL을 쓰고, 앱 토큰은 X-Gotify-Key 헤더에 넣어 주세요.",
|
||||
"email": "이메일",
|
||||
|
||||
+2
-1
@@ -6017,7 +6017,7 @@
|
||||
"ntfyAuthToken": "Token uwierzytelniania (opcjonalny)",
|
||||
"ntfyAuthTokenHint": "Wymagany tylko dla prywatnych tematów z kontrolą dostępu.",
|
||||
"webhook": "Webhook",
|
||||
"webhookDescription": "Wysyłaj powiadomienia metodą POST na dowolny URL — Gotify, Discord, Slack, Matrix lub Home Assistant.",
|
||||
"webhookDescription": "Powiadomienia metodą POST na dowolny URL: Discord, Slack, Gotify, Home Assistant, n8n lub przekaźnik przyjmujący JSON.",
|
||||
"webhookEnable": "Włącz webhook",
|
||||
"webhookUrl": "Adres URL webhooka",
|
||||
"webhookUrlHint": "Pełny adres URL dla żądania POST. Adres w sieci prywatnej musi zatwierdzić administrator serwera.",
|
||||
@@ -6025,6 +6025,7 @@
|
||||
"webhookHeaderValue": "Wartość nagłówka (opcjonalnie)",
|
||||
"webhookFormat": "Format wiadomości",
|
||||
"webhookFormatGeneric": "Ogólny JSON",
|
||||
"webhookFormatGenericHint": "Wklej adres webhooka Discorda lub Slacka bez zmian. Home Assistant i n8n odczytują pola title, message, eventType i priority.",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "Użyj adresu URL kończącego się na /message, a token aplikacji wpisz w nagłówku X-Gotify-Key.",
|
||||
"email": "E-mail",
|
||||
|
||||
@@ -42,8 +42,7 @@ const NOT_GRANTABLE_REFUSAL =
|
||||
* GET: current config (header value redacted — only presence flagged).
|
||||
* PUT: upsert config.
|
||||
*
|
||||
* Covers Gotify / Discord / Slack / Matrix-bridge / Home Assistant in one
|
||||
* channel: the user supplies a URL and an optional shared-secret header. SSRF
|
||||
* Covers Discord / Slack / Gotify / Home Assistant / n8n in one channel: the user supplies a URL and an optional shared-secret header. SSRF
|
||||
* is enforced at input time (the schema's target predicate) and again at
|
||||
* dispatch time (`safeFetch` with the connect-time pin); a private origin
|
||||
* passes both only when the operator listed it in
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* The webhook test button against a Discord webhook URL, through the real
|
||||
* sender with only the network mocked.
|
||||
*
|
||||
* The mocked target answers the way Discord does: 400 with code 50006
|
||||
* ("Cannot send an empty message") for a body without `content` or `embeds`,
|
||||
* 204 otherwise. The generic body used to carry neither, so the test button
|
||||
* and every reminder failed against a Discord URL the settings card names.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
notificationChannel: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `encrypted:${value}`),
|
||||
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
|
||||
}));
|
||||
|
||||
const safeFetchMock = vi.fn();
|
||||
vi.mock("@/lib/safe-fetch", () => ({
|
||||
safeFetch: (...args: unknown[]) => safeFetchMock(...args),
|
||||
SafeFetchError: class SafeFetchError extends Error {
|
||||
kind: string;
|
||||
constructor(message: string, kind: string) {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
|
||||
recordPushAttempt: vi.fn(),
|
||||
recordPushAttemptForPayload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST as postTest } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const DISCORD_URL =
|
||||
"https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789";
|
||||
|
||||
/** Answers the way Discord's execute-webhook endpoint does. */
|
||||
function discordAnswer(_url: string, init: { body: string }): Response {
|
||||
const body = JSON.parse(init.body) as Record<string, unknown>;
|
||||
const content = typeof body.content === "string" ? body.content : "";
|
||||
if (content.trim().length === 0 && body.embeds === undefined) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Cannot send an empty message", code: 50006 }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (content.length > 2000) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Invalid Form Body", code: 50035 }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
const POST = postTest as (request: Request) => Promise<Response>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" },
|
||||
} as never);
|
||||
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
|
||||
config: `encrypted:${JSON.stringify({ url: DISCORD_URL })}`,
|
||||
} as never);
|
||||
safeFetchMock.mockImplementation(
|
||||
async (url: string, init: { body: string }) => discordAnswer(url, init),
|
||||
);
|
||||
});
|
||||
|
||||
describe("POST /api/settings/webhook/test — a Discord webhook URL", () => {
|
||||
it("delivers: the generic body carries the content Discord requires", async () => {
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/settings/webhook/test", {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json()).data).toEqual({ sent: true });
|
||||
|
||||
expect(safeFetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = safeFetchMock.mock.calls[0] as [
|
||||
string,
|
||||
{ body: string },
|
||||
];
|
||||
expect(url).toBe(DISCORD_URL);
|
||||
const sent = JSON.parse(init.body);
|
||||
expect(sent.content).toBe(
|
||||
"HealthLog Test\nHealthLog: Connection successful! Webhook notifications are active.",
|
||||
);
|
||||
expect(sent.allowed_mentions).toEqual({ parse: [] });
|
||||
});
|
||||
|
||||
it("passes Discord's own error through when the target still refuses", async () => {
|
||||
safeFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ message: "Unknown Webhook", code: 10015 }),
|
||||
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/settings/webhook/test", {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
const json = await response.json();
|
||||
expect(json.meta.upstreamStatus).toBe(404);
|
||||
expect(json.meta.upstreamBody).toContain("Unknown Webhook");
|
||||
});
|
||||
});
|
||||
@@ -71,8 +71,8 @@ export function NotificationChannelsPanel() {
|
||||
<WebPushCard />
|
||||
</div>
|
||||
)}
|
||||
{/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack /
|
||||
Matrix / Home Assistant in one channel). */}
|
||||
{/* v1.17.1 — generic outbound webhook (Discord / Slack / Gotify /
|
||||
Home Assistant / n8n in one channel). */}
|
||||
<div id="webhook" className="scroll-mt-28">
|
||||
<WebhookCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
|
||||
@@ -209,11 +209,11 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
{t("settings.webhookFormatGotify")}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
{format === "gotify" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.webhookFormatGotifyHint")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{format === "gotify"
|
||||
? t("settings.webhookFormatGotifyHint")
|
||||
: t("settings.webhookFormatGenericHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-header-name">
|
||||
|
||||
@@ -43,7 +43,7 @@ vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
|
||||
) => recordPushAttemptMock(attempt),
|
||||
}));
|
||||
|
||||
import { sendViaWebhook } from "../webhook";
|
||||
import { buildWebhookBody, sendViaWebhook } from "../webhook";
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
const config = {
|
||||
@@ -345,30 +345,39 @@ function sentBody(): string {
|
||||
return (safeFetchMock.mock.calls[0][1] as { body: string }).body;
|
||||
}
|
||||
|
||||
// The chat-target fields appended after the four original ones (v1.42.0).
|
||||
const CHAT_TAIL =
|
||||
',"content":"Title\\nBody","text":"Title\\nBody","allowed_mentions":{"parse":[]}}';
|
||||
|
||||
describe("sendViaWebhook — generic body stays byte for byte", () => {
|
||||
// Literal strings, not re-parsed objects: a Home Assistant or n8n rule
|
||||
// that matches on the raw body must keep matching after the format choice
|
||||
// was added.
|
||||
// was added. The four original fields keep their values and their order;
|
||||
// the Discord and Slack fields only follow them.
|
||||
it.each([
|
||||
[
|
||||
"a routine event",
|
||||
{},
|
||||
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"default"}',
|
||||
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"default"' +
|
||||
CHAT_TAIL,
|
||||
],
|
||||
[
|
||||
"a medication reminder",
|
||||
{ eventType: "MEDICATION_REMINDER" },
|
||||
'{"title":"Title","message":"Body","eventType":"MEDICATION_REMINDER","priority":"high"}',
|
||||
'{"title":"Title","message":"Body","eventType":"MEDICATION_REMINDER","priority":"high"' +
|
||||
CHAT_TAIL,
|
||||
],
|
||||
[
|
||||
"an urgent event",
|
||||
{ urgent: true },
|
||||
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"urgent"}',
|
||||
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"urgent"' +
|
||||
CHAT_TAIL,
|
||||
],
|
||||
[
|
||||
"a discreet cycle event",
|
||||
{ eventType: "CYCLE_PERIOD_SOON", discreet: true },
|
||||
'{"title":"Title","message":"Body","eventType":"reminder","priority":"default"}',
|
||||
'{"title":"Title","message":"Body","eventType":"reminder","priority":"default"' +
|
||||
CHAT_TAIL,
|
||||
],
|
||||
])("%s", async (_label, over, expected) => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
@@ -382,6 +391,107 @@ describe("sendViaWebhook — generic body stays byte for byte", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWebhookBody — Discord and Slack", () => {
|
||||
const generic = { format: "generic" as const };
|
||||
|
||||
it("carries content for Discord and text for Slack, as plain title and message", () => {
|
||||
const body = JSON.parse(
|
||||
buildWebhookBody(
|
||||
generic,
|
||||
payload({ title: "Reminder", message: "<b>Take</b> your dose" }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(body.content).toBe("Reminder\nTake your dose");
|
||||
expect(body.text).toBe("Reminder\nTake your dose");
|
||||
expect(body).toMatchObject({
|
||||
title: "Reminder",
|
||||
message: "Take your dose",
|
||||
eventType: "SYSTEM_ALERT",
|
||||
priority: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("tells Discord to resolve no mentions", () => {
|
||||
const body = JSON.parse(
|
||||
buildWebhookBody(
|
||||
generic,
|
||||
payload({ message: "@everyone @here <@123456789012345678>" }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(body.allowed_mentions).toEqual({ parse: [] });
|
||||
// The text itself is left alone; the empty parse list is what disarms it.
|
||||
expect(body.content).toContain("@everyone");
|
||||
});
|
||||
|
||||
it("keeps content within Discord's 2000 characters, ending in an ellipsis", () => {
|
||||
const long = "x".repeat(5000);
|
||||
const body = JSON.parse(
|
||||
buildWebhookBody(generic, payload({ message: long })),
|
||||
);
|
||||
|
||||
expect(Array.from(body.content as string)).toHaveLength(2000);
|
||||
expect(body.content.startsWith("Title\nxxx")).toBe(true);
|
||||
expect(body.content.endsWith("\u2026")).toBe(true);
|
||||
// Slack and the relay fields keep the whole message.
|
||||
expect(body.text).toBe(`Title\n${long}`);
|
||||
expect(body.message).toBe(long);
|
||||
});
|
||||
|
||||
it("leaves content alone at exactly 2000 characters", () => {
|
||||
const message = "y".repeat(2000 - "Title\n".length);
|
||||
const body = JSON.parse(buildWebhookBody(generic, payload({ message })));
|
||||
|
||||
expect(body.content).toBe(`Title\n${message}`);
|
||||
expect(body.content).not.toContain("\u2026");
|
||||
});
|
||||
|
||||
it("never splits a surrogate pair when it cuts", () => {
|
||||
const body = JSON.parse(
|
||||
buildWebhookBody(
|
||||
generic,
|
||||
payload({
|
||||
eventType: "SYSTEM_ALERT",
|
||||
message: "\u{1F600}".repeat(3000),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const points = Array.from(body.content as string);
|
||||
expect(points).toHaveLength(2000);
|
||||
expect(body.content).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/);
|
||||
});
|
||||
|
||||
it("puts only the masked text into content and text in discreet mode", () => {
|
||||
// The dispatcher masks title and message upstream; the body must not
|
||||
// reintroduce the cycle event anywhere.
|
||||
const raw = buildWebhookBody(
|
||||
generic,
|
||||
payload({
|
||||
eventType: "CYCLE_PERIOD_SOON",
|
||||
discreet: true,
|
||||
title: "HealthLog",
|
||||
message: "HealthLog reminder",
|
||||
}),
|
||||
);
|
||||
const body = JSON.parse(raw);
|
||||
|
||||
expect(raw).not.toContain("CYCLE");
|
||||
expect(body.eventType).toBe("reminder");
|
||||
expect(body.content).toBe("HealthLog\nHealthLog reminder");
|
||||
expect(body.text).toBe("HealthLog\nHealthLog reminder");
|
||||
});
|
||||
|
||||
it("leaves the Gotify body without the chat fields", () => {
|
||||
const body = JSON.parse(buildWebhookBody({ format: "gotify" }, payload()));
|
||||
|
||||
expect(body).not.toHaveProperty("content");
|
||||
expect(body).not.toHaveProperty("text");
|
||||
expect(body).not.toHaveProperty("allowed_mentions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendViaWebhook — Gotify format (#947)", () => {
|
||||
const gotify = {
|
||||
url: "https://gotify.example.com/message",
|
||||
|
||||
@@ -57,8 +57,9 @@ const GOTIFY_PRIORITY: Record<PriorityBand, number> = {
|
||||
* The request body for the configured format.
|
||||
*
|
||||
* Discreet mode (cycle privacy) is honoured the same way in both: the title
|
||||
* and message arrive already masked upstream, and the event name a relay
|
||||
* could route on is replaced by the generic `reminder`.
|
||||
* and message arrive already masked upstream, every text field (including the
|
||||
* generic body's `content` and `text`) is built from them, and the event name
|
||||
* a relay could route on is replaced by the generic `reminder`.
|
||||
*/
|
||||
export function buildWebhookBody(
|
||||
config: Pick<WebhookChannelConfig, "format">,
|
||||
@@ -83,22 +84,50 @@ export function buildWebhookBody(
|
||||
});
|
||||
}
|
||||
|
||||
// Generic envelope. Discord and Slack ignore unknown keys, so one shape
|
||||
// covers the common JSON relays. Unchanged since v1.18.4: an existing
|
||||
// Home Assistant or n8n rule parses these exact fields.
|
||||
// Generic envelope. The first four fields are unchanged since v1.18.4, in
|
||||
// the same order: an existing Home Assistant or n8n rule parses them.
|
||||
// Discord and Slack do not read them. Discord answers 400 "Cannot send an
|
||||
// empty message" unless the body carries `content` (or `embeds`), and a
|
||||
// Slack incoming webhook needs `text`. Both get the same plain text,
|
||||
// built from the already masked title and message, so discreet mode
|
||||
// holds. `allowed_mentions` with an empty `parse` stops Discord from
|
||||
// resolving `@everyone`, `@here` or `<@id>` that a message might contain.
|
||||
// Each target ignores the keys meant for the others.
|
||||
const text = [title, message].filter((part) => part.length > 0).join("\n");
|
||||
return JSON.stringify({
|
||||
title,
|
||||
message,
|
||||
eventType,
|
||||
priority: band,
|
||||
content: truncateCodePoints(text, DISCORD_CONTENT_LIMIT),
|
||||
text,
|
||||
allowed_mentions: { parse: [] },
|
||||
});
|
||||
}
|
||||
|
||||
/** Discord rejects a `content` longer than 2000 characters. */
|
||||
const DISCORD_CONTENT_LIMIT = 2000;
|
||||
|
||||
/**
|
||||
* Cut `value` to at most `limit` code points, the last one an ellipsis when
|
||||
* anything was dropped. Counts code points, not UTF-16 units, so a cut never
|
||||
* splits a surrogate pair.
|
||||
*/
|
||||
function truncateCodePoints(value: string, limit: number): string {
|
||||
const points = Array.from(value);
|
||||
if (points.length <= limit) return value;
|
||||
return `${points
|
||||
.slice(0, limit - 1)
|
||||
.join("")
|
||||
.trimEnd()}\u2026`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification via a generic outbound webhook (v1.17.1).
|
||||
*
|
||||
* The user supplies a URL and optionally one custom header, and chooses the
|
||||
* body shape: HealthLog's generic JSON envelope, or the body Gotify's
|
||||
* body shape: HealthLog's generic JSON envelope (which a Discord or Slack
|
||||
* webhook URL also accepts as is), or the body Gotify's
|
||||
* `POST /message` binds (see `buildWebhookBody`). Gotify takes its app token
|
||||
* as an `X-Gotify-Key` header or a `token` query parameter. The body is plain
|
||||
* text (no markdown — hard rule); `title`/`message` are stripped of HTML and
|
||||
|
||||
@@ -398,7 +398,7 @@ export function webhookSettingsSchemaWith(
|
||||
.enum(WEBHOOK_PAYLOAD_FORMATS)
|
||||
.optional()
|
||||
.describe(
|
||||
"Body shape. `generic` sends HealthLog's own JSON envelope; `gotify` sends the body Gotify's `POST /message` expects, with an integer priority. Omitted keeps the stored choice, which is `generic` for a config saved before the choice existed.",
|
||||
"Body shape. `generic` sends HealthLog's own JSON envelope (`title`, `message`, `eventType`, `priority`, plus `content` and `text` so a Discord or Slack webhook URL accepts it as is); `gotify` sends the body Gotify's `POST /message` expects, with an integer priority. Omitted keeps the stored choice, which is `generic` for a config saved before the choice existed.",
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user