Merge v142/webhook-discord into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 14:22:13 +02:00
commit 745bcb64b7
16 files changed
+336 -32

No files matched your search

+8
View File
@@ -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
+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+1 -2
View File
@@ -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>
+5 -5
View File
@@ -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",
+35 -6
View File
@@ -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
+1 -1
View File
@@ -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(),
});