@crewhaus/tool-notify 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Message structure, and the escaping that keeps a value from becoming it.
3
+ *
4
+ * A notification is almost always assembled from data the harness did not
5
+ * write: a branch name, an error string, a customer's subject line. If that
6
+ * data reaches a chat platform unescaped it stops being a value and becomes
7
+ * syntax — `<!channel>` on Slack pages everyone in the room, `@everyone` on
8
+ * Discord does the same, and a stray ``` turns the rest of the message into
9
+ * a code block. That is markup injection, and it is the reason this file
10
+ * exists separately from the tools: escaping is a pure function that can be
11
+ * tested exhaustively, and every path that puts text on a platform goes
12
+ * through it.
13
+ *
14
+ * The blocks are deliberately few. Five kinds cover a notification — a
15
+ * heading, a paragraph, a list of name/value fields, a rule, a link — and
16
+ * each renders to something every platform actually supports. A richer model
17
+ * would render well on one platform and badly on the others, which is worse
18
+ * than a plain one that renders the same everywhere.
19
+ */
20
+ export type Platform = "slack" | "discord" | "teams" | "webhook";
21
+ export type Block = {
22
+ readonly kind: "heading";
23
+ readonly text: string;
24
+ } | {
25
+ readonly kind: "paragraph";
26
+ readonly text: string;
27
+ } | {
28
+ readonly kind: "fields";
29
+ readonly fields: ReadonlyArray<{
30
+ readonly name: string;
31
+ readonly value: string;
32
+ }>;
33
+ } | {
34
+ readonly kind: "divider";
35
+ } | {
36
+ readonly kind: "link";
37
+ readonly url: string;
38
+ readonly text?: string;
39
+ };
40
+ /**
41
+ * Escape a VALUE for a platform, so it can only ever be read as text.
42
+ *
43
+ * - **slack**: `&`, `<` and `>` are the only three characters Slack treats
44
+ * as markup structure, and escaping them is exactly what Slack's own docs
45
+ * prescribe. It kills `<!channel>`, `<!here>`, `<@U123>` and the
46
+ * `<url|label>` link form in one move. Slack's `*bold*`/`_italic_` marks
47
+ * are cosmetic — they cannot change who is notified or where a link goes
48
+ * — so they are left alone rather than backslash-littering every message.
49
+ * - **discord**: backslash-escape the characters that open markdown
50
+ * constructs, so a value cannot close a code fence or start a spoiler.
51
+ * Mentions are NOT handled here: Discord has no in-content escape for
52
+ * them, and the correct fix is the `allowed_mentions` field on the
53
+ * request, which `buildChatPayload` always sets to "parse nothing".
54
+ * - **teams**: HTML-escape first, because Teams renders a card's text as
55
+ * HTML, then backslash-escape the markdown marks it also honours.
56
+ * - **webhook**: identity. A generic webhook receives JSON; JSON encoding
57
+ * is the escaping, and mangling the value on the way would corrupt a
58
+ * payload nobody asked to be rendered.
59
+ */
60
+ export declare function escapeFor(platform: Platform, value: string): string;
61
+ /**
62
+ * Is a URL safe to put behind a link?
63
+ *
64
+ * `javascript:`, `data:` and `vbscript:` in a chat message are a phishing
65
+ * payload aimed at whoever clicks. Only http and https get rendered as
66
+ * links; anything else is refused rather than silently downgraded, because a
67
+ * caller who asked for a link and got plain text would not notice.
68
+ */
69
+ export declare function linkSchemeAllowed(url: string): boolean;
70
+ /**
71
+ * Render a text body plus blocks into one string for a platform.
72
+ *
73
+ * Blocks with an unusable link are dropped and reported rather than
74
+ * rendered, so a `javascript:` URL never reaches a reader.
75
+ */
76
+ export declare function renderBlocks(platform: Platform, text: string | undefined, blocks: readonly Block[]): {
77
+ readonly rendered: string;
78
+ readonly dropped: readonly string[];
79
+ };
80
+ /** Platform byte/character limits, so a message is cut here rather than there. */
81
+ export declare const PLATFORM_TEXT_LIMIT: Readonly<Record<Platform, number>>;
82
+ /**
83
+ * Cut `text` to a platform's limit, marking the cut. Counting is in UTF-16
84
+ * code units, which is what every one of these platforms documents its limit
85
+ * in, and the cut never splits a surrogate pair.
86
+ */
87
+ export declare function clampToPlatform(platform: Platform, text: string): {
88
+ readonly text: string;
89
+ readonly truncated: boolean;
90
+ };
91
+ export type ChatPayload = {
92
+ readonly body: string;
93
+ readonly contentType: string;
94
+ };
95
+ /**
96
+ * The JSON body a platform's incoming-webhook or API endpoint expects.
97
+ *
98
+ * Slack and Discord both accept a thread reference, and both are told not to
99
+ * resolve mentions: Slack via `link_names: false` plus the escaping above,
100
+ * Discord via `allowed_mentions: { parse: [] }`, which is the only reliable
101
+ * way to stop `@everyone` in a message body from paging a server.
102
+ */
103
+ export declare function buildChatPayload(input: {
104
+ readonly platform: Platform;
105
+ readonly text: string;
106
+ readonly threadId?: string | undefined;
107
+ readonly channel?: string | undefined;
108
+ readonly username?: string | undefined;
109
+ }): ChatPayload;
110
+ /** Platforms whose posted messages can be edited or deleted by id. */
111
+ export declare const EDITABLE_PLATFORMS: ReadonlySet<Platform>;
112
+ /** Platforms that carry a thread identifier on a post. */
113
+ export declare const THREADED_PLATFORMS: ReadonlySet<Platform>;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Message structure, and the escaping that keeps a value from becoming it.
3
+ *
4
+ * A notification is almost always assembled from data the harness did not
5
+ * write: a branch name, an error string, a customer's subject line. If that
6
+ * data reaches a chat platform unescaped it stops being a value and becomes
7
+ * syntax — `<!channel>` on Slack pages everyone in the room, `@everyone` on
8
+ * Discord does the same, and a stray ``` turns the rest of the message into
9
+ * a code block. That is markup injection, and it is the reason this file
10
+ * exists separately from the tools: escaping is a pure function that can be
11
+ * tested exhaustively, and every path that puts text on a platform goes
12
+ * through it.
13
+ *
14
+ * The blocks are deliberately few. Five kinds cover a notification — a
15
+ * heading, a paragraph, a list of name/value fields, a rule, a link — and
16
+ * each renders to something every platform actually supports. A richer model
17
+ * would render well on one platform and badly on the others, which is worse
18
+ * than a plain one that renders the same everywhere.
19
+ */
20
+ /**
21
+ * Escape a VALUE for a platform, so it can only ever be read as text.
22
+ *
23
+ * - **slack**: `&`, `<` and `>` are the only three characters Slack treats
24
+ * as markup structure, and escaping them is exactly what Slack's own docs
25
+ * prescribe. It kills `<!channel>`, `<!here>`, `<@U123>` and the
26
+ * `<url|label>` link form in one move. Slack's `*bold*`/`_italic_` marks
27
+ * are cosmetic — they cannot change who is notified or where a link goes
28
+ * — so they are left alone rather than backslash-littering every message.
29
+ * - **discord**: backslash-escape the characters that open markdown
30
+ * constructs, so a value cannot close a code fence or start a spoiler.
31
+ * Mentions are NOT handled here: Discord has no in-content escape for
32
+ * them, and the correct fix is the `allowed_mentions` field on the
33
+ * request, which `buildChatPayload` always sets to "parse nothing".
34
+ * - **teams**: HTML-escape first, because Teams renders a card's text as
35
+ * HTML, then backslash-escape the markdown marks it also honours.
36
+ * - **webhook**: identity. A generic webhook receives JSON; JSON encoding
37
+ * is the escaping, and mangling the value on the way would corrupt a
38
+ * payload nobody asked to be rendered.
39
+ */
40
+ export function escapeFor(platform, value) {
41
+ switch (platform) {
42
+ case "slack":
43
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
44
+ case "discord":
45
+ // Only the marks that introduce STRUCTURE: code fences, emphasis,
46
+ // spoilers, block quotes and links. `#`, `-` and `1.` make a heading or
47
+ // a list at the start of a line and cannot change what the message
48
+ // does, so they are left alone rather than littering every hyphen in
49
+ // every branch name with a backslash.
50
+ return value.replace(/([\\`*_~|>[\]()])/g, "\\$1");
51
+ case "teams":
52
+ return value
53
+ .replace(/&/g, "&amp;")
54
+ .replace(/</g, "&lt;")
55
+ .replace(/>/g, "&gt;")
56
+ .replace(/([\\`*_~\[\]])/g, "\\$1");
57
+ case "webhook":
58
+ return value;
59
+ }
60
+ }
61
+ /**
62
+ * Is a URL safe to put behind a link?
63
+ *
64
+ * `javascript:`, `data:` and `vbscript:` in a chat message are a phishing
65
+ * payload aimed at whoever clicks. Only http and https get rendered as
66
+ * links; anything else is refused rather than silently downgraded, because a
67
+ * caller who asked for a link and got plain text would not notice.
68
+ */
69
+ export function linkSchemeAllowed(url) {
70
+ try {
71
+ const parsed = new URL(url);
72
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ /** Render one block for a platform. Values are escaped; structure is not. */
79
+ function renderBlock(platform, block) {
80
+ switch (block.kind) {
81
+ case "heading": {
82
+ const text = escapeFor(platform, block.text);
83
+ if (platform === "slack")
84
+ return `*${text}*`;
85
+ if (platform === "webhook")
86
+ return block.text;
87
+ return `**${text}**`;
88
+ }
89
+ case "paragraph":
90
+ return escapeFor(platform, block.text);
91
+ case "fields": {
92
+ const lines = block.fields.map((f) => {
93
+ const name = escapeFor(platform, f.name);
94
+ const value = escapeFor(platform, f.value);
95
+ if (platform === "slack")
96
+ return `*${name}:* ${value}`;
97
+ if (platform === "webhook")
98
+ return `${f.name}: ${f.value}`;
99
+ return `**${name}:** ${value}`;
100
+ });
101
+ return lines.join("\n");
102
+ }
103
+ case "divider":
104
+ return platform === "slack" ? "───" : "---";
105
+ case "link": {
106
+ const label = block.text ?? block.url;
107
+ if (platform === "slack") {
108
+ // The URL goes inside Slack's own link syntax unescaped — escaping it
109
+ // would break the link — but the LABEL is escaped, because a label
110
+ // containing `|` or `>` would otherwise close the construct early and
111
+ // let the rest of the label become markup.
112
+ return `<${block.url}|${escapeFor("slack", label)}>`;
113
+ }
114
+ if (platform === "webhook")
115
+ return `${label}: ${block.url}`;
116
+ return `[${escapeFor(platform, label)}](${block.url})`;
117
+ }
118
+ }
119
+ }
120
+ /**
121
+ * Render a text body plus blocks into one string for a platform.
122
+ *
123
+ * Blocks with an unusable link are dropped and reported rather than
124
+ * rendered, so a `javascript:` URL never reaches a reader.
125
+ */
126
+ export function renderBlocks(platform, text, blocks) {
127
+ const dropped = [];
128
+ const parts = [];
129
+ if (text !== undefined && text !== "")
130
+ parts.push(escapeFor(platform, text));
131
+ for (const block of blocks) {
132
+ if (block.kind === "link" && !linkSchemeAllowed(block.url)) {
133
+ dropped.push("a link block whose URL was not http(s)");
134
+ continue;
135
+ }
136
+ parts.push(renderBlock(platform, block));
137
+ }
138
+ return { rendered: parts.join("\n\n"), dropped };
139
+ }
140
+ /** Platform byte/character limits, so a message is cut here rather than there. */
141
+ export const PLATFORM_TEXT_LIMIT = {
142
+ slack: 40_000,
143
+ discord: 2_000,
144
+ teams: 28_000,
145
+ webhook: 100_000,
146
+ };
147
+ /**
148
+ * Cut `text` to a platform's limit, marking the cut. Counting is in UTF-16
149
+ * code units, which is what every one of these platforms documents its limit
150
+ * in, and the cut never splits a surrogate pair.
151
+ */
152
+ export function clampToPlatform(platform, text) {
153
+ const limit = PLATFORM_TEXT_LIMIT[platform];
154
+ if (text.length <= limit)
155
+ return { text, truncated: false };
156
+ const marker = "\n… (truncated)";
157
+ let cut = limit - marker.length;
158
+ const code = text.charCodeAt(cut - 1);
159
+ if (code >= 0xd800 && code <= 0xdbff)
160
+ cut -= 1; // do not split a surrogate pair
161
+ return { text: `${text.slice(0, cut)}${marker}`, truncated: true };
162
+ }
163
+ /**
164
+ * The JSON body a platform's incoming-webhook or API endpoint expects.
165
+ *
166
+ * Slack and Discord both accept a thread reference, and both are told not to
167
+ * resolve mentions: Slack via `link_names: false` plus the escaping above,
168
+ * Discord via `allowed_mentions: { parse: [] }`, which is the only reliable
169
+ * way to stop `@everyone` in a message body from paging a server.
170
+ */
171
+ export function buildChatPayload(input) {
172
+ const { platform, text } = input;
173
+ if (platform === "slack") {
174
+ return {
175
+ body: JSON.stringify({
176
+ text,
177
+ link_names: false,
178
+ ...(input.channel !== undefined ? { channel: input.channel } : {}),
179
+ ...(input.threadId !== undefined ? { thread_ts: input.threadId } : {}),
180
+ ...(input.username !== undefined ? { username: input.username } : {}),
181
+ }),
182
+ contentType: "application/json",
183
+ };
184
+ }
185
+ if (platform === "discord") {
186
+ return {
187
+ body: JSON.stringify({
188
+ content: text,
189
+ allowed_mentions: { parse: [] },
190
+ ...(input.username !== undefined ? { username: input.username } : {}),
191
+ }),
192
+ contentType: "application/json",
193
+ };
194
+ }
195
+ if (platform === "teams") {
196
+ return {
197
+ body: JSON.stringify({
198
+ "@type": "MessageCard",
199
+ "@context": "https://schema.org/extensions",
200
+ text,
201
+ }),
202
+ contentType: "application/json",
203
+ };
204
+ }
205
+ return {
206
+ body: JSON.stringify({
207
+ text,
208
+ ...(input.channel !== undefined ? { channel: input.channel } : {}),
209
+ ...(input.threadId !== undefined ? { threadId: input.threadId } : {}),
210
+ }),
211
+ contentType: "application/json",
212
+ };
213
+ }
214
+ /** Platforms whose posted messages can be edited or deleted by id. */
215
+ export const EDITABLE_PLATFORMS = new Set(["slack", "discord"]);
216
+ /** Platforms that carry a thread identifier on a post. */
217
+ export const THREADED_PLATFORMS = new Set(["slack", "discord"]);
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Folding many events into one message.
3
+ *
4
+ * This is the function that stops a harness sending forty notifications when
5
+ * one would do. A watcher loop that fires per event will, on a bad morning,
6
+ * page a channel forty times with the same failure; a digest turns that into
7
+ * "38× connection refused (host-a, host-b), 2× timeout" and the reader
8
+ * learns more from it.
9
+ *
10
+ * Pure, and deliberately boring about order: groups are ranked by count and
11
+ * ties are broken by key, with a plain codepoint comparison rather than
12
+ * `localeCompare`, so the same events always fold to the same bytes.
13
+ */
14
+ import type { Block } from "./blocks";
15
+ export type DigestEvent = {
16
+ /** What makes two events "the same". Events sharing a key are counted. */
17
+ readonly key: string;
18
+ /** One line about this occurrence. The first one seen is the exemplar. */
19
+ readonly summary?: string;
20
+ /** Free-form labels; distinct values are listed under the group. */
21
+ readonly labels?: Readonly<Record<string, string>>;
22
+ /** Severity, used only for ordering when `sort` is "severity". */
23
+ readonly severity?: "info" | "warning" | "error" | "critical";
24
+ };
25
+ export type DigestGroup = {
26
+ readonly key: string;
27
+ readonly count: number;
28
+ readonly summary?: string;
29
+ readonly severity?: DigestEvent["severity"];
30
+ /** label name → the distinct values seen, sorted and capped. */
31
+ readonly labels: Readonly<Record<string, readonly string[]>>;
32
+ };
33
+ export type Digest = {
34
+ readonly total: number;
35
+ readonly distinct: number;
36
+ readonly groups: readonly DigestGroup[];
37
+ /** Groups that did not fit under `maxGroups`, and how many events they held. */
38
+ readonly omittedGroups: number;
39
+ readonly omittedEvents: number;
40
+ };
41
+ /**
42
+ * Fold events into groups.
43
+ *
44
+ * `maxGroups` caps what is rendered, not what is counted: the totals always
45
+ * describe every event handed in, and the omitted tail is reported as a
46
+ * number rather than dropped silently. A digest that quietly loses events is
47
+ * the failure mode that makes people distrust digests.
48
+ */
49
+ export declare function buildDigest(events: readonly DigestEvent[], options?: {
50
+ readonly maxGroups?: number;
51
+ readonly sort?: "count" | "severity" | "key";
52
+ }): Digest;
53
+ /** One line per group: `12× build failed — host: a, b`. */
54
+ export declare function digestToText(digest: Digest, title?: string): string;
55
+ /** The same digest as blocks, for a platform that renders them. */
56
+ export declare function digestToBlocks(digest: Digest, title?: string): Block[];
@@ -0,0 +1,140 @@
1
+ const SEVERITY_RANK = {
2
+ critical: 0,
3
+ error: 1,
4
+ warning: 2,
5
+ info: 3,
6
+ };
7
+ function byString(a, b) {
8
+ return a < b ? -1 : a > b ? 1 : 0;
9
+ }
10
+ /** Cap on distinct values listed per label, so one group cannot flood the digest. */
11
+ const MAX_LABEL_VALUES = 8;
12
+ /**
13
+ * Fold events into groups.
14
+ *
15
+ * `maxGroups` caps what is rendered, not what is counted: the totals always
16
+ * describe every event handed in, and the omitted tail is reported as a
17
+ * number rather than dropped silently. A digest that quietly loses events is
18
+ * the failure mode that makes people distrust digests.
19
+ */
20
+ export function buildDigest(events, options = {}) {
21
+ const maxGroups = Math.max(1, options.maxGroups ?? 10);
22
+ const sort = options.sort ?? "count";
23
+ const groups = new Map();
24
+ events.forEach((event, index) => {
25
+ let group = groups.get(event.key);
26
+ if (group === undefined) {
27
+ group = {
28
+ key: event.key,
29
+ count: 0,
30
+ labels: new Map(),
31
+ firstIndex: index,
32
+ ...(event.summary !== undefined ? { summary: event.summary } : {}),
33
+ ...(event.severity !== undefined ? { severity: event.severity } : {}),
34
+ };
35
+ groups.set(event.key, group);
36
+ }
37
+ group.count += 1;
38
+ // The most severe occurrence wins: a group that was warning-then-critical
39
+ // is a critical group, and rendering it as a warning would understate it.
40
+ if (event.severity !== undefined) {
41
+ const current = group.severity;
42
+ if (current === undefined ||
43
+ (SEVERITY_RANK[event.severity] ?? 9) < (SEVERITY_RANK[current] ?? 9)) {
44
+ group.severity = event.severity;
45
+ }
46
+ }
47
+ for (const [name, value] of Object.entries(event.labels ?? {})) {
48
+ let values = group.labels.get(name);
49
+ if (values === undefined) {
50
+ values = new Set();
51
+ group.labels.set(name, values);
52
+ }
53
+ values.add(value);
54
+ }
55
+ });
56
+ const ordered = [...groups.values()].sort((a, b) => {
57
+ if (sort === "key")
58
+ return byString(a.key, b.key);
59
+ if (sort === "severity") {
60
+ const sa = SEVERITY_RANK[a.severity ?? "info"] ?? 9;
61
+ const sb = SEVERITY_RANK[b.severity ?? "info"] ?? 9;
62
+ if (sa !== sb)
63
+ return sa - sb;
64
+ }
65
+ if (b.count !== a.count)
66
+ return b.count - a.count;
67
+ return byString(a.key, b.key);
68
+ });
69
+ const kept = ordered.slice(0, maxGroups);
70
+ const omitted = ordered.slice(maxGroups);
71
+ return {
72
+ total: events.length,
73
+ distinct: ordered.length,
74
+ omittedGroups: omitted.length,
75
+ omittedEvents: omitted.reduce((sum, g) => sum + g.count, 0),
76
+ groups: kept.map((group) => {
77
+ const labels = {};
78
+ for (const name of [...group.labels.keys()].sort(byString)) {
79
+ const values = [...(group.labels.get(name) ?? new Set())].sort(byString);
80
+ labels[name] = values.slice(0, MAX_LABEL_VALUES);
81
+ }
82
+ return {
83
+ key: group.key,
84
+ count: group.count,
85
+ labels,
86
+ ...(group.summary !== undefined ? { summary: group.summary } : {}),
87
+ ...(group.severity !== undefined ? { severity: group.severity } : {}),
88
+ };
89
+ }),
90
+ };
91
+ }
92
+ /** One line per group: `12× build failed — host: a, b`. */
93
+ export function digestToText(digest, title) {
94
+ const lines = [];
95
+ if (title !== undefined && title !== "")
96
+ lines.push(title);
97
+ lines.push(`${digest.total} events in ${digest.distinct} groups`);
98
+ for (const group of digest.groups) {
99
+ const labelText = Object.entries(group.labels)
100
+ .map(([name, values]) => `${name}: ${values.join(", ")}`)
101
+ .join("; ");
102
+ const head = `${group.count}× ${group.summary ?? group.key}`;
103
+ const severity = group.severity !== undefined ? ` [${group.severity}]` : "";
104
+ lines.push(`${head}${severity}${labelText === "" ? "" : ` — ${labelText}`}`);
105
+ }
106
+ if (digest.omittedGroups > 0) {
107
+ lines.push(`…and ${digest.omittedGroups} more groups covering ${digest.omittedEvents} events, not shown`);
108
+ }
109
+ return lines.join("\n");
110
+ }
111
+ /** The same digest as blocks, for a platform that renders them. */
112
+ export function digestToBlocks(digest, title) {
113
+ const blocks = [];
114
+ if (title !== undefined && title !== "")
115
+ blocks.push({ kind: "heading", text: title });
116
+ blocks.push({
117
+ kind: "paragraph",
118
+ text: `${digest.total} events in ${digest.distinct} groups`,
119
+ });
120
+ blocks.push({ kind: "divider" });
121
+ for (const group of digest.groups) {
122
+ const fields = [
123
+ { name: "count", value: String(group.count) },
124
+ ];
125
+ if (group.severity !== undefined)
126
+ fields.push({ name: "severity", value: group.severity });
127
+ for (const [name, values] of Object.entries(group.labels)) {
128
+ fields.push({ name, value: values.join(", ") });
129
+ }
130
+ blocks.push({ kind: "paragraph", text: group.summary ?? group.key });
131
+ blocks.push({ kind: "fields", fields });
132
+ }
133
+ if (digest.omittedGroups > 0) {
134
+ blocks.push({
135
+ kind: "paragraph",
136
+ text: `…and ${digest.omittedGroups} more groups covering ${digest.omittedEvents} events, not shown`,
137
+ });
138
+ }
139
+ return blocks;
140
+ }