@mikitasazan/notify 1.8.0 → 1.9.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.
- package/README.md +7 -6
- package/dist/cli.js +69 -60
- package/dist/events.d.ts +127 -117
- package/dist/events.js +30 -24
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/render.d.ts +57 -49
- package/dist/render.js +255 -221
- package/dist/routes.d.ts +31 -27
- package/dist/routes.js +25 -20
- package/dist/send.d.ts +1 -19
- package/dist/send.js +2 -33
- package/package.json +1 -1
package/dist/render.js
CHANGED
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* One renderer per event type, all built on one skeleton — approved by
|
|
3
|
+
* the owner on 20.08.2026 after ~15 live rounds in the test forum:
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* #type #instance
|
|
6
|
+
* icon <b>Type:</b> action
|
|
7
7
|
*
|
|
8
|
-
* <b
|
|
9
|
-
* <blockquote
|
|
8
|
+
* <b>Label:</b> value
|
|
9
|
+
* <blockquote>quoted text from someone else — commit body, task body</blockquote>
|
|
10
10
|
*
|
|
11
|
-
* <i><u
|
|
12
|
-
* <b>#N (overdue):</b> <a
|
|
11
|
+
* <i><u>Group</u></i>
|
|
12
|
+
* <b>#N (overdue):</b> <a>title</a>
|
|
13
13
|
*
|
|
14
|
-
* <b
|
|
14
|
+
* <b>Label:</b> value ← actions/directions
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* Three levels of styling, never mixed: a field is a bold, capitalized
|
|
17
|
+
* label plus a plain value; a group is italic+underline, no bold and no
|
|
18
|
+
* colon; line 2 (the type) follows the same field rule. A blank line
|
|
19
|
+
* separates BLOCKS BY MEANING (header / body / actions), not mechanically
|
|
20
|
+
* after every line.
|
|
21
21
|
*/
|
|
22
22
|
import { ICON, iconFor } from "./events.js";
|
|
23
|
-
/**
|
|
23
|
+
/** First letter capitalized, the rest left as is (ga4/GitHub stay themselves). */
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
* `iOS`
|
|
25
|
+
* A label gets a capital letter — but NOT a name that is deliberately
|
|
26
|
+
* written lowercase: `iOS` was turning into `IOS`. The signal is a capital
|
|
27
|
+
* second letter.
|
|
27
28
|
*/
|
|
28
29
|
const cap = (s) => {
|
|
29
30
|
if (s.length === 0 || /^[a-z][A-Z]/.test(s)) {
|
|
@@ -31,24 +32,25 @@ const cap = (s) => {
|
|
|
31
32
|
}
|
|
32
33
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
33
34
|
};
|
|
34
|
-
/**
|
|
35
|
+
/** Escapes EVERYTHING that comes from outside — only the template adds tags. */
|
|
35
36
|
export const esc = (v) => String(v ?? '')
|
|
36
37
|
.replace(/&/g, '&')
|
|
37
38
|
.replace(/</g, '<')
|
|
38
39
|
.replace(/>/g, '>')
|
|
39
40
|
.replace(/"/g, '"');
|
|
40
41
|
/**
|
|
41
|
-
* Telegram
|
|
42
|
-
*
|
|
42
|
+
* Telegram cuts a message at 4096 characters — we cut it ourselves first,
|
|
43
|
+
* on a line boundary where possible.
|
|
43
44
|
*
|
|
44
|
-
*
|
|
45
|
-
* 1.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
45
|
+
* Two traps, both caused a SILENT loss of the message:
|
|
46
|
+
* 1. Cutting strictly at the last `\n` does not work: if a long chunk runs
|
|
47
|
+
* as one line (a stack trace, command output — the most common `detail`
|
|
48
|
+
* on an incident), the last line break sits BEFORE it, and the whole
|
|
49
|
+
* content got dropped — only the heading arrived, with not a single
|
|
50
|
+
* fact about what broke.
|
|
51
|
+
* 2. Cutting in the middle of an HTML tag or entity does not work either:
|
|
52
|
+
* Telegram replies `400 can't parse entities`, and we treat a 4xx as a
|
|
53
|
+
* permanent error and do not retry — the message disappeared for good.
|
|
52
54
|
*/
|
|
53
55
|
export const clampMessage = (text, limit = 4000) => {
|
|
54
56
|
if (text.length <= limit) {
|
|
@@ -56,9 +58,9 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
56
58
|
}
|
|
57
59
|
const cut = text.slice(0, limit);
|
|
58
60
|
const lastBreak = cut.lastIndexOf('\n');
|
|
59
|
-
//
|
|
61
|
+
// Cut on a line boundary — only if that keeps most of the content.
|
|
60
62
|
let end = lastBreak > limit * 0.6 ? lastBreak : limit;
|
|
61
|
-
//
|
|
63
|
+
// Never cut inside `<...>` or inside `&...;` — otherwise the markup breaks.
|
|
62
64
|
const openTag = cut.lastIndexOf('<', end - 1);
|
|
63
65
|
if (openTag !== -1 && cut.indexOf('>', openTag) === -1) {
|
|
64
66
|
end = openTag;
|
|
@@ -68,21 +70,22 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
68
70
|
end = amp;
|
|
69
71
|
}
|
|
70
72
|
const body = cut.slice(0, end);
|
|
71
|
-
//
|
|
72
|
-
// blockquote —
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
73
|
+
// The clamp may have cut off closing tags — we add them back so the markup
|
|
74
|
+
// matches. blockquote — since quoting arrived for notes/details, a long
|
|
75
|
+
// detail gets cut right in the middle of it, and without this tag Telegram
|
|
76
|
+
// would answer 400 on the unclosed quote (the regex `<blockquote[ >]` also
|
|
77
|
+
// catches the variant with an `expandable` attribute).
|
|
78
|
+
// `u` joined the list on 2026-08-25: a group heading renders as
|
|
79
|
+
// `<i><u>…</u></i>`, and a long heading cut in the middle left `<u>` unclosed.
|
|
80
|
+
// Telegram answers 400 to that — meaning the whole card disappeared, and the
|
|
81
|
+
// sender with `|| true` never noticed. Codex found it; reproduces with a
|
|
82
|
+
// report whose group name is 5000 characters long.
|
|
80
83
|
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
84
|
+
// Closing order is the reverse of OPENING order, not a fixed list. A fixed
|
|
85
|
+
// list closed `<i><u>` as `</i></u>`: the tag count matches, but the nesting
|
|
86
|
+
// is wrong, and Telegram answers with the same 400. Second round of the same
|
|
87
|
+
// bug (25.08.2026), so now the order comes from the text itself: the last
|
|
88
|
+
// one opened is the first one closed.
|
|
86
89
|
const open = [];
|
|
87
90
|
const tagRe = /<(\/?)(b|a|i|u|code|blockquote)[ >]/g;
|
|
88
91
|
for (let m = tagRe.exec(body); m !== null; m = tagRe.exec(body)) {
|
|
@@ -102,12 +105,12 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
102
105
|
.join('');
|
|
103
106
|
return `${body}${tail}\n…`;
|
|
104
107
|
};
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
108
|
+
// Only the first line: a field is single-line by contract (commit, branch,
|
|
109
|
+
// author, a stat), not a place for a paragraph. A live case (18.08): a CI card
|
|
110
|
+
// carried the FULL commit body with a sub-commit history through `--commit`
|
|
111
|
+
// and instead of one line unrolled to 3000 characters — multi-line text is
|
|
112
|
+
// either a caller mistake, or it belongs in `note()`, not a silent bloat of
|
|
113
|
+
// the card.
|
|
111
114
|
const firstLine = (value) => {
|
|
112
115
|
if (typeof value === 'number' || !value.includes('\n')) {
|
|
113
116
|
return value;
|
|
@@ -115,16 +118,16 @@ const firstLine = (value) => {
|
|
|
115
118
|
return `${value.split('\n')[0]}…`;
|
|
116
119
|
};
|
|
117
120
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
121
|
+
* A field: `<b>Label:</b> value` — a bold, capitalized label, a plain value.
|
|
122
|
+
* `null` is dropped the same as `undefined`/`''` — the field's sources are
|
|
123
|
+
* JSON on stdin (`--json`) and objects from the server, where a missing
|
|
124
|
+
* value serializes as `null`, not as a missing key.
|
|
122
125
|
*/
|
|
123
126
|
const field = (label, value) => value === undefined || value === null || value === '' ? null : `<b>${esc(cap(label))}:</b> ${esc(firstLine(value))}`;
|
|
124
127
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
+
* An identifier field (`commit:`/`pr:`/`issue:`): the value is a link if
|
|
129
|
+
* one exists, otherwise the plain text of the same field — an identifier
|
|
130
|
+
* must not disappear entirely just because the caller did not pass a url.
|
|
128
131
|
*/
|
|
129
132
|
const fieldLink = (label, url, text) => {
|
|
130
133
|
if (text === undefined || text === null || text === '') {
|
|
@@ -138,41 +141,44 @@ const fieldLink = (label, url, text) => {
|
|
|
138
141
|
: field(label, text);
|
|
139
142
|
};
|
|
140
143
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
+
* An action field (`workflow:`): unlike `fieldLink`, without a URL this is
|
|
145
|
+
* NOT a field — the run simply has nowhere to lead, and showing the bare
|
|
146
|
+
* word "run" with no link is more meaningless than not showing the row at
|
|
147
|
+
* all.
|
|
144
148
|
*/
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
+
// The link text is the name of where it leads (the workflow's name, the run's
|
|
150
|
+
// name). The fallback word used to be "run": a noun that names nothing — the
|
|
151
|
+
// owner read "Workflow: run" and did not understand what it was. "open" is a
|
|
152
|
+
// verb, and it is at least honest about being a link, not a name.
|
|
149
153
|
const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'open')}</a>` : null;
|
|
150
|
-
/**
|
|
154
|
+
/** A monospace field — a path/command to copy, not a link. */
|
|
151
155
|
const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
|
|
152
156
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
157
|
+
* A row that asks something FROM THE OWNER, rather than reports a fact. It
|
|
158
|
+
* already stood last, set off by a blank line, and still read as an
|
|
159
|
+
* ordinary field among five others. The `▶` marker is the only difference:
|
|
160
|
+
* a group heading here would have been a third line of markup on a card
|
|
161
|
+
* that already has six (25.08.2026, two reviews against grouping), and the
|
|
162
|
+
* marker spends none.
|
|
158
163
|
*/
|
|
159
164
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
165
|
+
* Action: what to do and with what. Without an explanation the command is
|
|
166
|
+
* NOT printed at all — the owner on a bare `rm` in a card: "I don't even
|
|
167
|
+
* know what I'm doing." Silently dropping the row is better than showing
|
|
168
|
+
* him a command he cannot read; the sender is caught by the catalogue test
|
|
169
|
+
* instead, not by silence in the chat.
|
|
164
170
|
*/
|
|
165
171
|
const fieldRun = (value, why) => {
|
|
166
172
|
const explain = field('To do', why);
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// Telegram
|
|
173
|
+
// No icon. `▶` was the only symbol of its kind across all twenty kinds of
|
|
174
|
+
// cards, and the owner rightly asked what it meant: nothing that the row
|
|
175
|
+
// `To do:` above it and the monospace font below it did not already say —
|
|
176
|
+
// Telegram makes a row like that tap-to-copy on its own.
|
|
171
177
|
return value && explain !== null ? [explain, `<code>${esc(value)}</code>`] : [];
|
|
172
178
|
};
|
|
173
|
-
/**
|
|
179
|
+
/** A group heading: italic + underline, no bold, no colon. */
|
|
174
180
|
const group = (name) => `<i><u>${esc(cap(name))}</u></i>`;
|
|
175
|
-
/**
|
|
181
|
+
/** An item inside a group: `<b>label:</b> <a>text</a>` — or a plain bulleted/numbered row with no label. */
|
|
176
182
|
const groupItem = (it, index, numbered) => {
|
|
177
183
|
const linked = it.url ? `<a href="${esc(it.url)}">${esc(it.text)}</a>` : esc(it.text);
|
|
178
184
|
if (it.label) {
|
|
@@ -180,10 +186,11 @@ const groupItem = (it, index, numbered) => {
|
|
|
180
186
|
}
|
|
181
187
|
return numbered ? `${index + 1}. ${linked}` : `• ${linked}`;
|
|
182
188
|
};
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// Bot API),
|
|
189
|
+
// A long explanation (a note, incident details) — as a quote: in Telegram
|
|
190
|
+
// that is a bar on the left and a light indent, reading as "details," not as
|
|
191
|
+
// part of the heading. Longer than ~400 characters and the quote collapses
|
|
192
|
+
// on its own (`expandable`, Bot API), otherwise a stack trace or a log dump
|
|
193
|
+
// stretches the card across the whole screen.
|
|
187
194
|
const EXPAND_AT = 400;
|
|
188
195
|
const note = (text) => {
|
|
189
196
|
if (!text) {
|
|
@@ -193,11 +200,12 @@ const note = (text) => {
|
|
|
193
200
|
return body.length > EXPAND_AT ? `<blockquote expandable>${body}</blockquote>` : `<blockquote>${body}</blockquote>`;
|
|
194
201
|
};
|
|
195
202
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
203
|
+
* A quote with a caption. A bare quote reads as a continuation of the
|
|
204
|
+
* field above it: the owner asked about the line that opens a session,
|
|
205
|
+
* "what does this text mean, where does it come from" — and he was right,
|
|
206
|
+
* the card says it nowhere. The caption stands on its own line, because
|
|
207
|
+
* the text itself does not fit in a field: a field holds one line and cuts
|
|
208
|
+
* it.
|
|
201
209
|
*/
|
|
202
210
|
/**
|
|
203
211
|
* A quote that needs saying what it is. The heading is a GROUP heading — the
|
|
@@ -208,10 +216,10 @@ const note = (text) => {
|
|
|
208
216
|
*/
|
|
209
217
|
const quoted = (label, text) => text ? `${group(label)}\n${note(text)}` : null;
|
|
210
218
|
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
219
|
+
* Assembles the card. A blank line here marks a block change, not
|
|
220
|
+
* indentation: two in a row mean an empty block, a leading one means a
|
|
221
|
+
* block that does not exist. Both appear when some fields did not arrive,
|
|
222
|
+
* and both collapse here, not separately in every renderer.
|
|
215
223
|
*/
|
|
216
224
|
const join = (parts) => {
|
|
217
225
|
const out = [];
|
|
@@ -229,12 +237,13 @@ const join = (parts) => {
|
|
|
229
237
|
}
|
|
230
238
|
return out.join('\n');
|
|
231
239
|
};
|
|
232
|
-
/**
|
|
240
|
+
/** A flat list of items (no labels) — job/report with no groups. */
|
|
233
241
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
242
|
+
* List items. A named group ALWAYS prints its heading — the same law
|
|
243
|
+
* `labelled` follows: a card of one type must not look different on
|
|
244
|
+
* different days. Numbering runs within the block, not across it: "1, 2"
|
|
245
|
+
* under its own heading reads fine, a running "3, 4" under the second one
|
|
246
|
+
* does not.
|
|
238
247
|
*/
|
|
239
248
|
const bullets = (items, numbered) => {
|
|
240
249
|
const list = items ?? [];
|
|
@@ -251,42 +260,45 @@ const bullets = (items, numbered) => {
|
|
|
251
260
|
}
|
|
252
261
|
return out;
|
|
253
262
|
};
|
|
254
|
-
/**
|
|
263
|
+
/** A whole named group: heading + items, separated by a blank line inside the call, via join. */
|
|
255
264
|
const renderGroup = (g) => [
|
|
256
265
|
group(g.name),
|
|
257
266
|
...g.items.map((it, i) => groupItem(it, i, false))
|
|
258
267
|
];
|
|
259
268
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
269
|
+
* ONE rule for every card that has both a title and a body: the title is
|
|
270
|
+
* an ordinary `Title:` field, the body is a quote, and the quote holds
|
|
271
|
+
* nothing else.
|
|
262
272
|
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
273
|
+
* The title used to sit INSIDE THE QUOTE together with the body, separated
|
|
274
|
+
* by a blank line. The owner found the problem with that: the title is the
|
|
275
|
+
* main thing on a card, WHAT it is about, and it sat there as gray text of
|
|
276
|
+
* the same weight as the description — the only way to tell them apart was
|
|
277
|
+
* the blank line. On a PR with no body, the card degenerated into a single
|
|
278
|
+
* lonely gray one-line quote.
|
|
268
279
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
280
|
+
* The title is cut to its first line: a multi-line commit subject must not
|
|
281
|
+
* drag its own body into the field.
|
|
271
282
|
*/
|
|
272
283
|
const titleField = (title) => field('Title', title);
|
|
273
284
|
const bodyQuote = (body) => body ? note(body) : null;
|
|
274
285
|
/**
|
|
275
|
-
*
|
|
286
|
+
* Labelled rows, sorted into the groups the sender itself named.
|
|
276
287
|
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
288
|
+
* The rule is simple and enforced by code: named a group — the heading
|
|
289
|
+
* prints. Always, no matter how many rows are in it or how many groups
|
|
290
|
+
* turn up. I tried a "two or more" threshold and dropped it: on the backup
|
|
291
|
+
* card all the numbers sit in one group, with a summary of the run above
|
|
292
|
+
* them, and the threshold was killing exactly the seam the owner asked
|
|
293
|
+
* for in the first place.
|
|
282
294
|
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
295
|
+
* This also removes the risk of "the same card looks different on
|
|
296
|
+
* different days": the look depends on what the sender NAMED in code, not
|
|
297
|
+
* on how many rows happened to show up today.
|
|
286
298
|
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
299
|
+
* Group order is the order the sender first mentions them: it knows what
|
|
300
|
+
* matters more. Unnamed rows come first with no heading — they are facts
|
|
301
|
+
* about the card itself, not about any one of its subjects.
|
|
290
302
|
*/
|
|
291
303
|
const labelled = (rows) => {
|
|
292
304
|
const list = rows ?? [];
|
|
@@ -303,10 +315,10 @@ const labelled = (rows) => {
|
|
|
303
315
|
}
|
|
304
316
|
}
|
|
305
317
|
for (const name of names) {
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
// `join
|
|
318
|
+
// A blank line before EVERY heading, including the first: above it there
|
|
319
|
+
// are always the card's own fields (Task, Period), and without the seam
|
|
320
|
+
// the heading read as just another one of them. No need to worry about
|
|
321
|
+
// double blanks — `join` collapses them.
|
|
310
322
|
out.push('');
|
|
311
323
|
out.push(group(name));
|
|
312
324
|
for (const [label, value] of list.filter(([, , g]) => g === name)) {
|
|
@@ -319,18 +331,19 @@ const labelled = (rows) => {
|
|
|
319
331
|
return out;
|
|
320
332
|
};
|
|
321
333
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* actor, workflow —
|
|
334
|
+
* Blocks owned by the renderer itself — a deploy and a check have two of
|
|
335
|
+
* them, and they are about different things: `Run` is the run itself and
|
|
336
|
+
* its circumstances, `Change` is the change that caused it. The owner on a
|
|
337
|
+
* CI card: "commit, actor, workflow — I don't know, it's all a jumble."
|
|
326
338
|
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
339
|
+
* The heading prints for EVERY non-empty block, not only when there are
|
|
340
|
+
* two. It used to be "two or more," to save a line on a green card, and
|
|
341
|
+
* that turned out to be a mistake: a green deploy has no target and no
|
|
342
|
+
* reason, there is only one block, the headings disappeared — and the same
|
|
343
|
+
* kind of notification looked different from one day to the next. The
|
|
344
|
+
* owner asked twice "why isn't there a group here," looking straight at a
|
|
345
|
+
* green one. A heading row costs less than having to hunt for what is what
|
|
346
|
+
* every single time.
|
|
334
347
|
*/
|
|
335
348
|
/**
|
|
336
349
|
* A deploy or a check has two subjects: the run itself and the commit it went
|
|
@@ -349,11 +362,12 @@ const twoBlocks = (run, change) => {
|
|
|
349
362
|
const changeRows = live(change);
|
|
350
363
|
return [...runRows, ...(changeRows.length > 0 ? ['', group('Change'), ...changeRows] : [])];
|
|
351
364
|
};
|
|
352
|
-
//
|
|
353
|
-
/**
|
|
354
|
-
// `action`
|
|
355
|
-
// JS,
|
|
356
|
-
//
|
|
365
|
+
// The icon and its rule live in events.ts: the sound depends on it too.
|
|
366
|
+
/** Line 2: the icon sits outside the bold, `<b>Type:</b> action` — the same field, not a special case. */
|
|
367
|
+
// `action` is typed as a string, but it also arrives from `--json` and from
|
|
368
|
+
// direct calls in JS, where there are no types. An empty or missing value
|
|
369
|
+
// produced the row `ℹ️ null` right on the card's second line. An empty string
|
|
370
|
+
// is more honest: the field simply disappears.
|
|
357
371
|
// A link belongs on the NAME of the thing it opens, never on a separate row
|
|
358
372
|
// whose only text is the verb `open`. The owner read `Details: open` under a
|
|
359
373
|
// report and asked what "open" was — the answer is the report itself, which was
|
|
@@ -369,17 +383,28 @@ const typeLine = (icon, type, action, url, aside) => {
|
|
|
369
383
|
// template prints the word "null". That is how line 2 of a card became
|
|
370
384
|
// `ℹ️ null` — reachable through `--json` and through a direct call from JS,
|
|
371
385
|
// where there are no types.
|
|
372
|
-
// `action || 'open'` in the linked case: an empty title must not swallow the
|
|
373
|
-
// link, which would be the one thing the card cannot afford to lose.
|
|
374
|
-
const line = url ? fieldLink(type, url, action || 'open') : field(type, action);
|
|
375
386
|
const tail = aside ? ` (${esc(aside)})` : '';
|
|
387
|
+
const name = action?.trim();
|
|
388
|
+
// No name: the card must not invent one. It used to write the word `open`
|
|
389
|
+
// into the identifier slot, so a report with a link and no title arrived as
|
|
390
|
+
// `Report: open` — and a deploy with neither fell back to its own status
|
|
391
|
+
// word, `Deploy: fail`, saying the outcome a third time after the icon and
|
|
392
|
+
// the tag. Both are the same mistake: a slot that must hold a name holding
|
|
393
|
+
// something else instead. The type word itself takes the link, so nothing
|
|
394
|
+
// clickable is lost and nothing false is said.
|
|
395
|
+
if (!name) {
|
|
396
|
+
const bare = `<b>${esc(cap(type))}</b>`;
|
|
397
|
+
return `${icon} ${url ? `<a href="${esc(url)}">${bare}</a>` : bare}${tail}`;
|
|
398
|
+
}
|
|
399
|
+
const line = url ? fieldLink(type, url, name) : field(type, name);
|
|
376
400
|
return line === null ? `${icon} <b>${esc(cap(type))}</b>${tail}` : `${icon} ${line}${tail}`;
|
|
377
401
|
};
|
|
378
|
-
// `workflowUrl ?? url`:
|
|
379
|
-
// `--url` —
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
402
|
+
// `workflowUrl ?? url`: half the senders send the run link under the name
|
|
403
|
+
// `--url` — that name was in the package before and stayed in their calls.
|
|
404
|
+
// The renderer only read `workflowUrl`, so a red card arrived WITH NOT A
|
|
405
|
+
// SINGLE LINK to the logs. Rejecting `--url` would be more honest by name and
|
|
406
|
+
// worse in practice: the intent is unambiguous, and a card with no link is
|
|
407
|
+
// useless exactly when it is needed most.
|
|
383
408
|
/**
|
|
384
409
|
* What to call the thing that ran. The workflow's own name first — it is the
|
|
385
410
|
* only text here that identifies THIS run. Then the caller's own word for the
|
|
@@ -390,7 +415,7 @@ const typeLine = (icon, type, action, url, aside) => {
|
|
|
390
415
|
* GitHub Action always fills the workflow name, and the hand-run scripts send
|
|
391
416
|
* no run link at all.
|
|
392
417
|
*/
|
|
393
|
-
const mechanism = (workflowName, via, runUrl) => workflowName ?? via
|
|
418
|
+
const mechanism = (workflowName, via, runUrl) => workflowName ?? via;
|
|
394
419
|
// The name of what ran sits WITH the type line, not eight lines below it.
|
|
395
420
|
// `Deploy: fail` and `by what means it ran` answer one question, and the owner
|
|
396
421
|
// read the two rows as unrelated things. It used to be one fact split in two:
|
|
@@ -407,10 +432,11 @@ const renderDeploy = (e) => {
|
|
|
407
432
|
const icon = iconFor(e);
|
|
408
433
|
const runUrl = e.workflowUrl ?? e.url;
|
|
409
434
|
return join([
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
435
|
+
// The name of what shipped the deploy sits on the type line. The outcome
|
|
436
|
+
// is already said by the icon and the third tag; there is nothing to
|
|
437
|
+
// repeat in words, and it is the same law a job and a report follow. The
|
|
438
|
+
// `Via` row is gone: it used to carry this same name one floor below.
|
|
439
|
+
typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via, runUrl), runUrl),
|
|
414
440
|
...twoBlocks([field('Target', e.target), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
|
|
415
441
|
]);
|
|
416
442
|
};
|
|
@@ -449,11 +475,11 @@ const renderJob = (e) => {
|
|
|
449
475
|
...(hasItems ? bullets(e.items, disabledList) : []),
|
|
450
476
|
e.command || e.logs ? '' : null,
|
|
451
477
|
fieldCode('Log', e.logs),
|
|
452
|
-
...fieldRun(e.command, e.commandNote)
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
478
|
+
...fieldRun(e.command, e.commandNote)
|
|
479
|
+
// No trailing `Workflow:` row. It pointed at `workflowUrl ?? url` — the
|
|
480
|
+
// exact address line 2 already carries — so it was one destination
|
|
481
|
+
// written twice, and it stood BELOW the `To do:` command, which is the
|
|
482
|
+
// last thing the card is supposed to say.
|
|
457
483
|
]);
|
|
458
484
|
};
|
|
459
485
|
const renderReport = (e) => {
|
|
@@ -492,7 +518,7 @@ const renderCi = (e) => {
|
|
|
492
518
|
const icon = iconFor(e);
|
|
493
519
|
const runUrl = e.workflowUrl ?? e.url;
|
|
494
520
|
return join([
|
|
495
|
-
typeLine(icon, 'CI', mechanism(e.workflowName, undefined, runUrl)
|
|
521
|
+
typeLine(icon, 'CI', mechanism(e.workflowName, undefined, runUrl), runUrl),
|
|
496
522
|
...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
|
|
497
523
|
]);
|
|
498
524
|
};
|
|
@@ -503,17 +529,28 @@ const renderCi = (e) => {
|
|
|
503
529
|
// The action is not repeated in words: the icon carries it, and no two actions
|
|
504
530
|
// of one type share an icon.
|
|
505
531
|
const named = (number, title) => title ? `#${number} ${title}` : `#${number}`;
|
|
532
|
+
// The people come BEFORE the text, and the text comes only when it is the
|
|
533
|
+
// news. An `assigned` card carries one new fact — who took it — and it used to
|
|
534
|
+
// sit dead last, under the issue's entire description: the owner read a card
|
|
535
|
+
// about someone taking issue #312 and asked who, because he never got that far.
|
|
536
|
+
//
|
|
537
|
+
// The description is the news exactly once, when the thing is opened. On
|
|
538
|
+
// assigned, closed, merged or a review verdict it is text he has already read,
|
|
539
|
+
// and it buries the one line he came for.
|
|
540
|
+
const opening = (action, body) => action === 'opened' ? bodyQuote(body) : null;
|
|
506
541
|
const renderPr = (e) => join([
|
|
507
542
|
typeLine(iconFor(e), 'PR', named(e.number, e.title), e.url),
|
|
508
|
-
bodyQuote(e.body),
|
|
509
543
|
field('Author', e.author),
|
|
510
|
-
field('Reviewer', e.reviewer)
|
|
544
|
+
field('Reviewer', e.reviewer),
|
|
545
|
+
e.action === 'opened' && e.body ? '' : null,
|
|
546
|
+
opening(e.action, e.body)
|
|
511
547
|
]);
|
|
512
548
|
const renderIssue = (e) => join([
|
|
513
549
|
typeLine(iconFor(e), 'Issue', named(e.number, e.title), e.url),
|
|
514
|
-
bodyQuote(e.body),
|
|
515
550
|
field('Author', e.author),
|
|
516
|
-
field('Assignee', e.assignee)
|
|
551
|
+
field('Assignee', e.assignee),
|
|
552
|
+
e.action === 'opened' && e.body ? '' : null,
|
|
553
|
+
opening(e.action, e.body)
|
|
517
554
|
]);
|
|
518
555
|
// The incident's own title IS line 2, exactly as an issue's is. It used to say
|
|
519
556
|
// the word `open` there — which the 🚨 already says, and no other card repeats
|
|
@@ -527,7 +564,7 @@ const renderIncident = (e) => join([
|
|
|
527
564
|
typeLine(iconFor(e), 'Incident', e.title, e.url),
|
|
528
565
|
e.detail && e.detail !== e.title ? note(e.detail) : null,
|
|
529
566
|
e.logs ? '' : null,
|
|
530
|
-
fieldCode('
|
|
567
|
+
fieldCode('Log', e.logs)
|
|
531
568
|
]);
|
|
532
569
|
// A session in trouble. Same law as every other card: identifier first, then
|
|
533
570
|
// the facts as fields, then his own words as a quote — never as a field, which
|
|
@@ -551,14 +588,16 @@ const renderSession = (e) => join([
|
|
|
551
588
|
]);
|
|
552
589
|
const renderHeartbeatMiss = (e) => {
|
|
553
590
|
const icon = iconFor(e);
|
|
554
|
-
const action = e.recovered ? 'ok' : 'miss';
|
|
555
591
|
return join([
|
|
556
592
|
// The task's name on the type line, exactly as a job card carries it. The
|
|
557
593
|
// `Task:` row said the same thing a floor below. No sender in any
|
|
558
594
|
// repository builds this event any more — the silence watchdog sends an
|
|
559
595
|
// ordinary job with `--status silent` — but a machine still running the old
|
|
560
596
|
// copy of that watchdog can, and the card it gets must obey the template.
|
|
561
|
-
|
|
597
|
+
// No bracket saying `ok` or `miss`: the icon says it, the third tag says
|
|
598
|
+
// it, and `miss` is not one of the five words the outcome is allowed to
|
|
599
|
+
// be. The bracket is for what finishes the NAME, never for a verdict.
|
|
600
|
+
typeLine(icon, 'Heartbeat', e.job),
|
|
562
601
|
field('Reason', e.note),
|
|
563
602
|
...schedule(e.expected, e.lastSeen, e.recovered ? 'Last run' : 'Last seen')
|
|
564
603
|
]);
|
|
@@ -574,21 +613,22 @@ const RENDERERS = {
|
|
|
574
613
|
session: renderSession,
|
|
575
614
|
heartbeat_miss: renderHeartbeatMiss
|
|
576
615
|
};
|
|
577
|
-
//
|
|
578
|
-
// (
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
616
|
+
// The tag at the top of the card AND the parser's machine key are ONE AND THE
|
|
617
|
+
// SAME value (the owner's decision, 20.08.2026): they used to be two separate
|
|
618
|
+
// representations of one fact (a hyphenated `#ci-arvent` at the bottom, tags
|
|
619
|
+
// typed by hand at the top), and that read as duplication. The separator is
|
|
620
|
+
// an underscore, not a hyphen: a hyphen splits a Telegram hashtag in the
|
|
621
|
+
// middle of a word (`#mac-config` links only as `#mac`), and the tag MUST be
|
|
622
|
+
// clickable — that is exactly the "show this instance's whole history" filter
|
|
623
|
+
// the owner uses in practice.
|
|
584
624
|
export const slug = (raw) => raw
|
|
585
625
|
.toLowerCase()
|
|
586
626
|
.replace(/[^\p{L}\p{N}]+/gu, '_')
|
|
587
627
|
.replace(/^_+|_+$/g, '')
|
|
588
628
|
.slice(0, 60);
|
|
589
|
-
//
|
|
590
|
-
// `#heartbeat_miss`,
|
|
591
|
-
// (
|
|
629
|
+
// The type tag at the top is not the literal `e.type`: `heartbeat_miss` would
|
|
630
|
+
// read as `#heartbeat_miss`, while the type the owner sees is always just
|
|
631
|
+
// `#heartbeat` (a green and a red card of one kind carry the same type tag).
|
|
592
632
|
const TYPE_TAG = {
|
|
593
633
|
deploy: 'deploy',
|
|
594
634
|
job: 'job',
|
|
@@ -601,12 +641,13 @@ const TYPE_TAG = {
|
|
|
601
641
|
heartbeat_miss: 'heartbeat'
|
|
602
642
|
};
|
|
603
643
|
/**
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
644
|
+
* The instance tag: exactly which concrete event this is (branch,
|
|
645
|
+
* environment, task, number) — the parser uses it to match a 🔴 against a
|
|
646
|
+
* later green card of the SAME instance. An explicit `key` always wins;
|
|
647
|
+
* without one, it is derived from the type's most stable fields (branch/
|
|
648
|
+
* environment outrank the title, because a recurring task's title does not
|
|
649
|
+
* change, while for a report the title is exactly the one stable field it
|
|
650
|
+
* has).
|
|
610
651
|
*/
|
|
611
652
|
export const eventKey = (e) => {
|
|
612
653
|
const fallback = () => {
|
|
@@ -631,23 +672,19 @@ export const eventKey = (e) => {
|
|
|
631
672
|
return `i${e.number}`;
|
|
632
673
|
}
|
|
633
674
|
};
|
|
634
|
-
|
|
675
|
+
// An empty instance tag (`#session # #fail`) is not a tag: it groups
|
|
676
|
+
// nothing and the parser cannot pair a red card with its green one. An
|
|
677
|
+
// untyped `--json` payload can leave every field it is derived from blank,
|
|
678
|
+
// so the project name is the last resort.
|
|
679
|
+
return (e.key ? slug(e.key) : fallback()) || slug(e.project) || 'event';
|
|
635
680
|
};
|
|
636
681
|
/**
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
* already the single source of truth for the sound, and a second list of "what
|
|
644
|
-
* counts as broken" would drift from the first — it already did once, when a
|
|
645
|
-
* red card arrived silent.
|
|
646
|
-
*
|
|
647
|
-
* One icon meaning, one tag. A watchdog that SWITCHED SOMETHING OFF is not a
|
|
648
|
-
* failure and must not be filed under the same word as one: the owner read
|
|
649
|
-
* `#fail` under a 🚫 and said so. Nor is a task that has simply gone quiet —
|
|
650
|
-
* nobody knows yet whether it broke, and `#unknown` is the honest word for it.
|
|
682
|
+
* The type is `Record` over EVERY icon, not over `string`. A new icon added to
|
|
683
|
+
* `ICON` without a word here now fails to compile. Under the old loose type it
|
|
684
|
+
* fell through a `?? 'news'` default instead: a card whose outcome nobody had
|
|
685
|
+
* decided was indistinguishable from a card that is genuinely just news, and
|
|
686
|
+
* nothing anywhere went red. `news` is therefore written out for each icon
|
|
687
|
+
* that means it, never left to a fallback.
|
|
651
688
|
*/
|
|
652
689
|
export const OUTCOME_TAG = {
|
|
653
690
|
[ICON.red]: 'fail',
|
|
@@ -656,40 +693,37 @@ export const OUTCOME_TAG = {
|
|
|
656
693
|
[ICON.unknown]: 'unknown',
|
|
657
694
|
[ICON.ok]: 'ok',
|
|
658
695
|
[ICON.landed]: 'ok',
|
|
659
|
-
[ICON.approved]: 'ok'
|
|
696
|
+
[ICON.approved]: 'ok',
|
|
697
|
+
// Something happened; no verdict was passed on it.
|
|
698
|
+
[ICON.fresh]: 'news',
|
|
699
|
+
[ICON.taken]: 'news',
|
|
700
|
+
[ICON.discarded]: 'news',
|
|
701
|
+
[ICON.changes]: 'news',
|
|
702
|
+
[ICON.info]: 'news'
|
|
660
703
|
};
|
|
661
|
-
|
|
662
|
-
// digest — is news: something happened, no verdict was passed.
|
|
663
|
-
export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)] ?? 'news';
|
|
704
|
+
export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)];
|
|
664
705
|
const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
|
|
665
706
|
/**
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*/
|
|
672
|
-
export const reportTags = (key) => `#report #${esc(slug(key))}`;
|
|
673
|
-
/**
|
|
674
|
-
* Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
|
|
675
|
-
* Теги — ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
|
|
676
|
-
* несут и человеческий фильтр, и машинный ключ разборщика — обрезанная
|
|
677
|
-
* карточка без них была бы не только некликабельной, но и невидимой
|
|
678
|
-
* разборщику ровно на самых длинных, то есть самых важных сообщениях.
|
|
707
|
+
* Renders an event into finished HTML text, cut to Telegram's limit.
|
|
708
|
+
* Tags are the FIRST line, added before the cut (not after, as before):
|
|
709
|
+
* they carry both the human filter and the parser's machine key — a card
|
|
710
|
+
* cut without them would be not only unclickable but invisible to the
|
|
711
|
+
* parser on exactly the longest, meaning the most important, messages.
|
|
679
712
|
*/
|
|
680
713
|
export const render = (e) => {
|
|
681
714
|
const renderer = RENDERERS[e.type];
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
// function`.
|
|
715
|
+
// Covers the `--json` path and calls from JS with no types: there `type`
|
|
716
|
+
// is a plain string, and an unknown value crashed the process through
|
|
717
|
+
// `renderer is not a function`. A notification must never crash anything.
|
|
685
718
|
if (typeof renderer !== 'function') {
|
|
686
719
|
throw new Error(`unknown event type: ${String(e.type)}`);
|
|
687
720
|
}
|
|
688
721
|
const tags = tagsLine(e);
|
|
689
|
-
// clampMessage
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
722
|
+
// clampMessage can go past the passed limit for the tail of closing tags
|
|
723
|
+
// and the ellipsis — minus 40 leaves it that margin. Messages already have
|
|
724
|
+
// their own margin (4000 against Telegram's 4096); for a caption the 1024
|
|
725
|
+
// limit is the real one. A card with an attachment is a caption, so the
|
|
726
|
+
// budget is chosen by `path`.
|
|
693
727
|
const budget = Math.max(64, e.path ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
|
|
694
728
|
return `${tags}\n${clampMessage(renderer(e), budget)}`;
|
|
695
729
|
};
|