mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge v142/webhook-discord into release/v1.42.0
This commit is contained in:
commit
745bcb64b7
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