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:
Marc-André Bombeck committed 2026-10-09 13:43:28 +02:00
1 parent d8a3614120
commit d029e4e1dc
16 files changed
+336 -32

No files matched your search

@@ -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(),
});