@mikitasazan/notify 1.9.0 → 1.10.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/dist/cli-flags.d.ts +7 -7
- package/dist/cli-flags.js +8 -8
- package/dist/lint.d.ts +20 -0
- package/dist/lint.js +90 -0
- package/dist/render.js +16 -3
- package/dist/send.js +108 -66
- package/dist/setup.js +13 -13
- package/package.json +1 -1
package/dist/cli-flags.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Every flag name the CLI can read. It exists for one reason: a typo in a
|
|
3
|
+
* flag name used to be ignored. `--noto=...` instead of `--note=...` made a
|
|
4
|
+
* card with a missing field and exited with code zero — the owner got a
|
|
5
|
+
* cut-down message, and the task that called it thought everything was fine.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* The list is closed and a test checks it: the test pulls every name that
|
|
8
|
+
* `one`/`num`/`pairs` reads out of this same file, and requires each one to
|
|
9
|
+
* be here. The list and the code cannot drift apart without the test seeing it.
|
|
10
10
|
*/
|
|
11
11
|
export declare const KNOWN_FLAGS: ReadonlySet<string>;
|
package/dist/cli-flags.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Every flag name the CLI can read. It exists for one reason: a typo in a
|
|
3
|
+
* flag name used to be ignored. `--noto=...` instead of `--note=...` made a
|
|
4
|
+
* card with a missing field and exited with code zero — the owner got a
|
|
5
|
+
* cut-down message, and the task that called it thought everything was fine.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* The list is closed and a test checks it: the test pulls every name that
|
|
8
|
+
* `one`/`num`/`pairs` reads out of this same file, and requires each one to
|
|
9
|
+
* be here. The list and the code cannot drift apart without the test seeing it.
|
|
10
10
|
*/
|
|
11
11
|
export const KNOWN_FLAGS = new Set([
|
|
12
12
|
'action', 'actor', 'assignee', 'author', 'body', 'branch', 'commit',
|
|
@@ -16,6 +16,6 @@ export const KNOWN_FLAGS = new Set([
|
|
|
16
16
|
'item-group', 'job', 'key', 'last-seen', 'line', 'logs', 'note',
|
|
17
17
|
'aside', 'number', 'path', 'period', 'project', 'reviewer', 'stat', 'status',
|
|
18
18
|
'target', 'title', 'url', 'via', 'workflow-name', 'workflow-url',
|
|
19
|
-
//
|
|
19
|
+
// Flags with no value. They live here too, so parsing and the list do not drift apart.
|
|
20
20
|
'json', 'recovered', 'dry-run'
|
|
21
21
|
]);
|
package/dist/lint.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The card checks itself, at the moment it is sent.
|
|
3
|
+
*
|
|
4
|
+
* Everything else that guards the template runs on examples: the tests on
|
|
5
|
+
* fixtures, the catalogue build on twenty-one hand-written cards. A live
|
|
6
|
+
* sender passes values none of them ever pass — an empty title, a status word
|
|
7
|
+
* from a `--json` payload, a number with a plus sign built by hand — and the
|
|
8
|
+
* card that reaches the owner is the one nobody looked at.
|
|
9
|
+
*
|
|
10
|
+
* So the finished HTML is read here, right before delivery, by the same rules
|
|
11
|
+
* the page states. A card that breaks them is STILL SENT: a notification is
|
|
12
|
+
* never worth losing, and a lint is not a reason to drop one. The breach is
|
|
13
|
+
* reported separately, as its own red card to mac-config, the way a lost
|
|
14
|
+
* project already is.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Reads a finished card and returns what is wrong with it, in the owner's
|
|
18
|
+
* terms. An empty array means the card obeys the standard.
|
|
19
|
+
*/
|
|
20
|
+
export declare const lintCard: (html: string) => string[];
|
package/dist/lint.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The card checks itself, at the moment it is sent.
|
|
3
|
+
*
|
|
4
|
+
* Everything else that guards the template runs on examples: the tests on
|
|
5
|
+
* fixtures, the catalogue build on twenty-one hand-written cards. A live
|
|
6
|
+
* sender passes values none of them ever pass — an empty title, a status word
|
|
7
|
+
* from a `--json` payload, a number with a plus sign built by hand — and the
|
|
8
|
+
* card that reaches the owner is the one nobody looked at.
|
|
9
|
+
*
|
|
10
|
+
* So the finished HTML is read here, right before delivery, by the same rules
|
|
11
|
+
* the page states. A card that breaks them is STILL SENT: a notification is
|
|
12
|
+
* never worth losing, and a lint is not a reason to drop one. The breach is
|
|
13
|
+
* reported separately, as its own red card to mac-config, the way a lost
|
|
14
|
+
* project already is.
|
|
15
|
+
*/
|
|
16
|
+
/** The words that are never a name, in the slot where the name belongs. */
|
|
17
|
+
const NOT_A_NAME = new Set([
|
|
18
|
+
'ok',
|
|
19
|
+
'fail',
|
|
20
|
+
'failed',
|
|
21
|
+
'error',
|
|
22
|
+
'success',
|
|
23
|
+
'disabled',
|
|
24
|
+
'silent',
|
|
25
|
+
'unknown',
|
|
26
|
+
'open',
|
|
27
|
+
'the run',
|
|
28
|
+
'run',
|
|
29
|
+
'done',
|
|
30
|
+
'undefined',
|
|
31
|
+
'null'
|
|
32
|
+
]);
|
|
33
|
+
/** The five words the third tag is allowed to be, and there is no sixth. */
|
|
34
|
+
const OUTCOMES = new Set(['ok', 'fail', 'off', 'unknown', 'news']);
|
|
35
|
+
/** Labels the template retired. Each one used to say what a neighbour said. */
|
|
36
|
+
const RETIRED = ['Title', 'Number', 'State', 'Via', 'Check', 'Logs', 'Task', 'Id', 'Period'];
|
|
37
|
+
/**
|
|
38
|
+
* Reads a finished card and returns what is wrong with it, in the owner's
|
|
39
|
+
* terms. An empty array means the card obeys the standard.
|
|
40
|
+
*/
|
|
41
|
+
export const lintCard = (html) => {
|
|
42
|
+
const rows = html.split('\n');
|
|
43
|
+
const found = [];
|
|
44
|
+
const tags = (rows[0] ?? '').trim().split(/\s+/);
|
|
45
|
+
if (tags.length !== 3 || !tags.every((t) => t.startsWith('#'))) {
|
|
46
|
+
found.push(`line 1 is "${rows[0] ?? ''}" — it must be exactly three tags`);
|
|
47
|
+
}
|
|
48
|
+
else if (!OUTCOMES.has(tags[2].slice(1))) {
|
|
49
|
+
found.push(`the outcome tag is "${tags[2]}" — the vocabulary is ok, fail, off, unknown, news`);
|
|
50
|
+
}
|
|
51
|
+
if (tags[1] === '#' || tags[1] === '#_') {
|
|
52
|
+
found.push('the instance tag is empty — it groups nothing and pairs with nothing');
|
|
53
|
+
}
|
|
54
|
+
const second = rows[1] ?? '';
|
|
55
|
+
if (!second) {
|
|
56
|
+
found.push('there is no line 2 — a card must say what it is about');
|
|
57
|
+
}
|
|
58
|
+
// The identifier: what stands after `Type:`, with or without a link on it.
|
|
59
|
+
const named = second.match(/<b>[^<]+:<\/b>\s*(?:<a href="[^"]*">)?([^<]*)/);
|
|
60
|
+
if (named && NOT_A_NAME.has(named[1].trim().toLowerCase())) {
|
|
61
|
+
found.push(`line 2 says "${named[1].trim()}" where the name of the thing belongs`);
|
|
62
|
+
}
|
|
63
|
+
for (const label of RETIRED) {
|
|
64
|
+
if (html.includes(`<b>${label}:</b>`)) {
|
|
65
|
+
found.push(`the row "${label}:" is back — that fact is already said somewhere else`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Every link must go somewhere a tap can reach, and must be named by what it
|
|
69
|
+
// opens. A local path is not a link at all — it is monospaced, to be copied.
|
|
70
|
+
for (const m of html.matchAll(/<a href="([^"]*)">([^<]*)<\/a>/g)) {
|
|
71
|
+
const [, href, text] = m;
|
|
72
|
+
if (!/^https?:\/\/\S+$/.test(href)) {
|
|
73
|
+
found.push(`the link "${text}" points at "${href}", which is not an address`);
|
|
74
|
+
}
|
|
75
|
+
if (NOT_A_NAME.has(text.trim().toLowerCase())) {
|
|
76
|
+
found.push(`a link named "${text}" — name the thing it opens, not the click`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// A number is either compared or it is not. A sign in front of it looks like
|
|
80
|
+
// a comparison and is not one.
|
|
81
|
+
for (const m of html.matchAll(/<b>([^<]+):<\/b> ([^\n<]*)/g)) {
|
|
82
|
+
if (/(^|\s)[+\-]\d/.test(m[2])) {
|
|
83
|
+
found.push(`"${m[1]}: ${m[2]}" — a signed number is not a comparison`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (/all good/i.test(html)) {
|
|
87
|
+
found.push('"all good" is a status, not a recommendation');
|
|
88
|
+
}
|
|
89
|
+
return found;
|
|
90
|
+
};
|
package/dist/render.js
CHANGED
|
@@ -280,7 +280,20 @@ const renderGroup = (g) => [
|
|
|
280
280
|
* The title is cut to its first line: a multi-line commit subject must not
|
|
281
281
|
* drag its own body into the field.
|
|
282
282
|
*/
|
|
283
|
-
|
|
283
|
+
/**
|
|
284
|
+
* The commit is one row, the way a task and a pull request are one row: the
|
|
285
|
+
* hash carries the link, the subject stands next to it. It used to take two —
|
|
286
|
+
* `Commit: a1b2c3d` and `Title: feat: new landing` underneath — and `Title:`
|
|
287
|
+
* was the same row the issue card had already lost for the same reason: you
|
|
288
|
+
* could not read what the card was about without reading two lines.
|
|
289
|
+
*/
|
|
290
|
+
const commitRow = (hash, url, title) => {
|
|
291
|
+
const linked = fieldLink('Commit', url, hash);
|
|
292
|
+
if (linked === null || !title) {
|
|
293
|
+
return linked ?? field('Commit', title);
|
|
294
|
+
}
|
|
295
|
+
return `${linked} ${esc(firstLine(title))}`;
|
|
296
|
+
};
|
|
284
297
|
const bodyQuote = (body) => body ? note(body) : null;
|
|
285
298
|
/**
|
|
286
299
|
* Labelled rows, sorted into the groups the sender itself named.
|
|
@@ -437,7 +450,7 @@ const renderDeploy = (e) => {
|
|
|
437
450
|
// repeat in words, and it is the same law a job and a report follow. The
|
|
438
451
|
// `Via` row is gone: it used to carry this same name one floor below.
|
|
439
452
|
typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via, runUrl), runUrl),
|
|
440
|
-
...twoBlocks([field('Target', e.target), field('Reason', e.note)], [
|
|
453
|
+
...twoBlocks([field('Target', e.target), field('Reason', e.note)], [commitRow(e.commit, e.commitUrl, e.commitTitle), bodyQuote(e.commitBody)])
|
|
441
454
|
]);
|
|
442
455
|
};
|
|
443
456
|
const schedule = (expected, lastSeen, lastLabel) => {
|
|
@@ -519,7 +532,7 @@ const renderCi = (e) => {
|
|
|
519
532
|
const runUrl = e.workflowUrl ?? e.url;
|
|
520
533
|
return join([
|
|
521
534
|
typeLine(icon, 'CI', mechanism(e.workflowName, undefined, runUrl), runUrl),
|
|
522
|
-
...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [
|
|
535
|
+
...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [commitRow(e.commit, e.commitUrl, e.commitTitle), bodyQuote(e.commitBody)])
|
|
523
536
|
]);
|
|
524
537
|
};
|
|
525
538
|
// A pull request and an issue are identified the way GitHub itself identifies
|
package/dist/send.js
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* game-publisher/scripts/lib/telegram.ts (fetch
|
|
4
|
-
* `.trim()`
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Transport. Carries over the code proven in production from
|
|
3
|
+
* game-publisher/scripts/lib/telegram.ts (fetch, with a curl fallback
|
|
4
|
+
* through stdin, and `.trim()` on the token) and adds what that code did not
|
|
5
|
+
* have: several targets per call, `message_thread_id`, a retry on HTTP 429
|
|
6
|
+
* that respects `retry_after`, a retry on 5xx, and a refusal with no retry
|
|
7
|
+
* on any other 4xx.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* The token comes ONLY from `process.env.OPS_BOT_TOKEN`, with `.trim()`: a
|
|
10
|
+
* newline in the token (a common find after copy-paste) makes curl read the
|
|
11
|
+
* config as two directives and leak the tail of the token into the run's
|
|
12
|
+
* stderr.
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
+
* No token means 'skipped', not an exception: a notification must never be
|
|
15
|
+
* allowed to bring down the deploy or the scheduled task that called it.
|
|
14
16
|
*/
|
|
15
17
|
import { execFileSync } from 'node:child_process';
|
|
16
18
|
import { readFileSync } from 'node:fs';
|
|
17
19
|
import { basename } from 'node:path';
|
|
18
20
|
import { render } from "./render.js";
|
|
21
|
+
import { lintCard } from "./lint.js";
|
|
19
22
|
import { ROUTES, targets } from "./routes.js";
|
|
20
23
|
const log = (msg) => {
|
|
21
|
-
// stderr,
|
|
24
|
+
// stderr, not stdout — stdout is reserved for possible machine output of the CLI.
|
|
22
25
|
console.error(`[notify] ${msg}`);
|
|
23
26
|
};
|
|
24
27
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -31,11 +34,12 @@ const buildBody = (target, text) => JSON.stringify({
|
|
|
31
34
|
disable_notification: target.silent
|
|
32
35
|
});
|
|
33
36
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
37
|
+
* Fallback path: curl uses a different TLS/DNS stack, and helps in places
|
|
38
|
+
* where fetch/undici cannot route the request. The URL with the token goes
|
|
39
|
+
* out as a config file through stdin, not as an argument: in argv it would
|
|
40
|
+
* be visible to any user on the server through `ps aux`. stderr is set to
|
|
41
|
+
* 'pipe', not inherited: a curl error message can contain a piece of the URL
|
|
42
|
+
* with the token, and it must not end up in the run's log.
|
|
39
43
|
*/
|
|
40
44
|
const sendViaCurl = (token, target, text) => {
|
|
41
45
|
const config = [
|
|
@@ -55,10 +59,10 @@ const sendViaCurl = (token, target, text) => {
|
|
|
55
59
|
return 'ok';
|
|
56
60
|
}
|
|
57
61
|
catch (err) {
|
|
58
|
-
// 28
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
+
// 28 is curl's own timeout (the `max-time` set above). Like a fetch
|
|
63
|
+
// timeout, it means "no answer came back", not "not delivered": a retry
|
|
64
|
+
// would put a second copy in the chat. Everything else (connection
|
|
65
|
+
// refused, 4xx with `fail`) is safe to retry.
|
|
62
66
|
return err.status === 28 ? 'fail' : 'retry';
|
|
63
67
|
}
|
|
64
68
|
};
|
|
@@ -81,34 +85,38 @@ const attempt = async (token, target, text) => {
|
|
|
81
85
|
if (res.status >= 500) {
|
|
82
86
|
return { outcome: 'retry', waitMs: 1000 };
|
|
83
87
|
}
|
|
84
|
-
// 4xx
|
|
85
|
-
//
|
|
88
|
+
// A 4xx other than 429 is a permanent error (wrong thread, the bot is
|
|
89
|
+
// not an admin, wrong chat_id). A retry will not fix it.
|
|
86
90
|
//
|
|
87
|
-
//
|
|
88
|
-
// (
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
+
// We always pull out the reason: Telegram puts it in `description`
|
|
92
|
+
// ("message thread not found", "can't parse entities"), and without it
|
|
93
|
+
// there is no way to understand why notifications went missing — and the
|
|
94
|
+
// one working it out will not be a developer, it will be the owner.
|
|
91
95
|
const detail = (await res.json().catch(() => null));
|
|
92
96
|
log(`HTTP ${res.status}: ${detail?.description ?? 'no description'} — permanent error, not retried`);
|
|
93
97
|
return { outcome: 'fail' };
|
|
94
98
|
}
|
|
95
99
|
catch (err) {
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
100
|
+
// A timeout is NOT the same thing as "not delivered": the request may
|
|
101
|
+
// have gone through, and only the answer failed to come back in time. A
|
|
102
|
+
// retry (whether by curl or by the next attempt) puts a second copy of
|
|
103
|
+
// the same message in the chat — the Bot API has no deduplication. So on
|
|
104
|
+
// a timeout we stop and honestly write 'failed': an extra copy of an
|
|
105
|
+
// alarm is worse than a missing line in the log, and the message most
|
|
106
|
+
// likely went out anyway.
|
|
101
107
|
if (err instanceof Error && err.name === 'TimeoutError') {
|
|
102
108
|
log('answer timed out — not retried: the message may already be out');
|
|
103
109
|
return { outcome: 'fail' };
|
|
104
110
|
}
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
111
|
+
// This branch catches connection failures (DNS, TLS, network
|
|
112
|
+
// unreachable) — the request almost certainly did not go out, so the
|
|
113
|
+
// curl fallback is safe. NOT absolutely safe: a break (reset or
|
|
114
|
+
// truncation) AFTER Telegram already accepted the POST also throws an
|
|
115
|
+
// exception here — then a retry produces a duplicate. This is a rare
|
|
116
|
+
// case; a full guarantee against duplicates is not possible without an
|
|
117
|
+
// idempotency key on the Bot API (it does not have one). The logic
|
|
118
|
+
// stays the same: a duplicate on a rare reset is a smaller problem than
|
|
119
|
+
// a lost message on a common network failure.
|
|
112
120
|
log('fetch did not go through, trying curl…');
|
|
113
121
|
const curl = sendViaCurl(token, target, text);
|
|
114
122
|
return curl === 'retry' ? { outcome: 'retry', waitMs: 1000 } : { outcome: curl };
|
|
@@ -140,10 +148,11 @@ const deliver = async (where, text) => {
|
|
|
140
148
|
log('skipped: no OPS_BOT_TOKEN, the message was not sent');
|
|
141
149
|
return 'skipped';
|
|
142
150
|
}
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
151
|
+
// The token is interpolated into the URL and into the curl config
|
|
152
|
+
// (`url = "...bot${token}..."`). A valid Telegram token matches
|
|
153
|
+
// `\d+:[\w-]+`; anything with a quote, a newline, or a `?` would break the
|
|
154
|
+
// parsing (a curl directive injection or a query tail). This needs a
|
|
155
|
+
// corrupted secret to happen, but the check is cheap.
|
|
147
156
|
if (!/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
|
|
148
157
|
log('failed: OPS_BOT_TOKEN does not look like a Telegram token, send cancelled');
|
|
149
158
|
return 'skipped';
|
|
@@ -152,19 +161,20 @@ const deliver = async (where, text) => {
|
|
|
152
161
|
return 'skipped';
|
|
153
162
|
}
|
|
154
163
|
const results = [];
|
|
155
|
-
//
|
|
156
|
-
//
|
|
164
|
+
// One after another, not Promise.all: a failure on one target must not run
|
|
165
|
+
// its retries in parallel with the rest and hammer the API on several chats at once.
|
|
157
166
|
for (const target of where) {
|
|
158
167
|
results.push(await sendOne(token, target, text));
|
|
159
168
|
}
|
|
160
169
|
return results.includes('sent') ? 'sent' : 'failed';
|
|
161
170
|
};
|
|
162
171
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
172
|
+
* A file (sendDocument) is multipart, so it does not go through `buildBody`.
|
|
173
|
+
* The retry policy is the same as for messages: 429 respects retry_after,
|
|
174
|
+
* 5xx retries, any other 4xx or a timeout does not (a duplicate file is
|
|
175
|
+
* worse than a missing one). There is no separate curl fallback here: the
|
|
176
|
+
* file is sent from this same machine, not from CI, and a different TLS
|
|
177
|
+
* stack has never been needed here.
|
|
168
178
|
*/
|
|
169
179
|
const sendFileOnce = async (token, target, e, caption) => {
|
|
170
180
|
try {
|
|
@@ -176,8 +186,8 @@ const sendFileOnce = async (token, target, e, caption) => {
|
|
|
176
186
|
form.append('caption', caption);
|
|
177
187
|
form.append('parse_mode', 'HTML');
|
|
178
188
|
form.append('disable_notification', String(target.silent));
|
|
179
|
-
// readFileSync + Blob,
|
|
180
|
-
//
|
|
189
|
+
// readFileSync + Blob, not openAsBlob: that appeared in Node 19.8, and
|
|
190
|
+
// this package also runs on the server. The files here are text reports, so memory is not a concern.
|
|
181
191
|
form.append('document', new Blob([readFileSync(e.path)]), e.filename ?? basename(e.path));
|
|
182
192
|
const res = await fetch(`https://api.telegram.org/bot${token}/sendDocument`, {
|
|
183
193
|
method: 'POST',
|
|
@@ -204,7 +214,7 @@ const sendFileOnce = async (token, target, e, caption) => {
|
|
|
204
214
|
log('answer timed out — not retried: the file may already be out');
|
|
205
215
|
return { outcome: 'fail' };
|
|
206
216
|
}
|
|
207
|
-
//
|
|
217
|
+
// The file cannot be read (not on disk, no permission) — a permanent error.
|
|
208
218
|
if (err instanceof Error && 'code' in err) {
|
|
209
219
|
log(`failed to send the file: ${err.message}`);
|
|
210
220
|
return { outcome: 'fail' };
|
|
@@ -225,7 +235,7 @@ const sendFile = async (e) => {
|
|
|
225
235
|
}
|
|
226
236
|
const caption = render(e);
|
|
227
237
|
const results = [];
|
|
228
|
-
//
|
|
238
|
+
// The same contract as deliver: a failure on one target does not cancel the rest.
|
|
229
239
|
for (const target of where) {
|
|
230
240
|
let waitMs = 0;
|
|
231
241
|
let got = 'failed';
|
|
@@ -248,19 +258,22 @@ const sendFile = async (e) => {
|
|
|
248
258
|
return results.includes('sent') ? 'sent' : 'failed';
|
|
249
259
|
};
|
|
250
260
|
/**
|
|
251
|
-
*
|
|
252
|
-
* `incidents`
|
|
253
|
-
*
|
|
261
|
+
* Sends the event to all of its targets (the project topic, plus
|
|
262
|
+
* `incidents` if needed, plus the team chat). Targets are handled one after
|
|
263
|
+
* another; a failure on one does not cancel the rest. Returns `'sent'` if at
|
|
264
|
+
* least one target got the message.
|
|
254
265
|
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
266
|
+
* An unknown project still does NOT bring down the scheduled task that
|
|
267
|
+
* called it (the exit code does not change) — but it no longer disappears
|
|
268
|
+
* silently either: a red card goes out to mac-config Ops. This kind of
|
|
269
|
+
* failure lived unnoticed for weeks, twice: "vault" and "mac-config" until
|
|
270
|
+
* 04.08, and the Alitools reports until 18.08. Recursion is not possible
|
|
271
|
+
* here: the error card is addressed to mac-config, which is always present
|
|
272
|
+
* in ROUTES.
|
|
260
273
|
*/
|
|
261
274
|
const reportLostProject = async (project, kind) => {
|
|
262
|
-
//
|
|
263
|
-
//
|
|
275
|
+
// The local log also names the valid spellings — this is the only
|
|
276
|
+
// diagnostic available on the machine where the typo happened.
|
|
264
277
|
log(`unknown project "${String(project)}" — known: ${Object.keys(ROUTES).join(', ')}`);
|
|
265
278
|
const lost = {
|
|
266
279
|
type: 'job',
|
|
@@ -272,9 +285,30 @@ const reportLostProject = async (project, kind) => {
|
|
|
272
285
|
};
|
|
273
286
|
await deliver(targets(lost), render(lost)).catch(() => undefined);
|
|
274
287
|
};
|
|
288
|
+
/**
|
|
289
|
+
* The card broke the standard. It still goes out — a notification is never
|
|
290
|
+
* worth losing over its own formatting — and the breach is raised as its own
|
|
291
|
+
* red card, the way a lost project is.
|
|
292
|
+
*
|
|
293
|
+
* `key` carries the type, so a renderer that starts producing broken deploy
|
|
294
|
+
* cards raises one running complaint rather than a new one every hour.
|
|
295
|
+
* Recursion is not possible: this card is not linted.
|
|
296
|
+
*/
|
|
297
|
+
const reportBrokenCard = async (e, faults) => {
|
|
298
|
+
log(`card does not match the standard: ${faults.join('; ')}`);
|
|
299
|
+
const broken = {
|
|
300
|
+
type: 'job',
|
|
301
|
+
project: 'mac-config',
|
|
302
|
+
job: 'notify: a card broke the standard',
|
|
303
|
+
status: 'fail',
|
|
304
|
+
note: `${String(e.type)} card for ${String(e.project)}: ${faults.join('; ')}`,
|
|
305
|
+
key: `notify-broken-${String(e.type)}`
|
|
306
|
+
};
|
|
307
|
+
await deliver(targets(broken), render(broken)).catch(() => undefined);
|
|
308
|
+
};
|
|
275
309
|
export const notify = async (e) => {
|
|
276
|
-
// Object.hasOwn,
|
|
277
|
-
// toString/constructor
|
|
310
|
+
// Object.hasOwn, not `in`: `in` walks the prototype chain, and
|
|
311
|
+
// --project toString/constructor would pass the guard, losing the event AND the card about the loss.
|
|
278
312
|
if (!Object.hasOwn(ROUTES, e.project)) {
|
|
279
313
|
await reportLostProject(e.project, String(e.type));
|
|
280
314
|
return 'skipped';
|
|
@@ -283,5 +317,13 @@ export const notify = async (e) => {
|
|
|
283
317
|
if (e.path) {
|
|
284
318
|
return sendFile(e);
|
|
285
319
|
}
|
|
286
|
-
|
|
320
|
+
const html = render(e);
|
|
321
|
+
// Send first, complain second: the delivery of the real card must not wait
|
|
322
|
+
// on, or be lost to, a check about how it looks.
|
|
323
|
+
const result = await deliver(targets(e), html);
|
|
324
|
+
const faults = lintCard(html);
|
|
325
|
+
if (faults.length > 0) {
|
|
326
|
+
await reportBrokenCard(e, faults);
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
287
329
|
};
|
package/dist/setup.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `notify setup "
|
|
3
|
-
*
|
|
2
|
+
* `notify setup "<Project name>"` creates the "⚙️ Ops" and "💬 Dev" tabs in a
|
|
3
|
+
* forum that already exists, and prints the ready line for `ROUTES`.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* 1.
|
|
8
|
-
* @mikita_ops_bot
|
|
9
|
-
* 2. `notify setup <chat_id>` —
|
|
10
|
-
* 3.
|
|
5
|
+
* The bot cannot create the forum supergroup itself — Telegram only allows a
|
|
6
|
+
* real account to do that. So the order for a new project is:
|
|
7
|
+
* 1. create a group in Telegram, turn on "Topics" in it, add
|
|
8
|
+
* @mikita_ops_bot as an admin with the "Manage topics" right;
|
|
9
|
+
* 2. run `notify setup <chat_id>` — it creates both tabs and prints the line;
|
|
10
|
+
* 3. paste the line into `src/routes.ts`.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Step 1 happens once per project and takes half a minute. Steps 2 and 3 are
|
|
13
|
+
* mechanical.
|
|
14
14
|
*/
|
|
15
15
|
const log = (msg) => console.error(`[notify] ${msg}`);
|
|
16
16
|
const createTopic = async (token, chat, name, color) => {
|
|
@@ -29,7 +29,7 @@ const createTopic = async (token, chat, name, color) => {
|
|
|
29
29
|
return body.result.message_thread_id;
|
|
30
30
|
}
|
|
31
31
|
catch (err) {
|
|
32
|
-
//
|
|
32
|
+
// Network error, timeout, or an unreadable response — do not crash the CLI (its contract: always exit 0).
|
|
33
33
|
log(`could not create "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
@@ -42,8 +42,8 @@ export const setupTopic = async (chatId, projectKey) => {
|
|
|
42
42
|
}
|
|
43
43
|
const ops = await createTopic(token, chatId, '⚙️ Ops', 9367192);
|
|
44
44
|
const dev = await createTopic(token, chatId, '💬 Dev', 7322096);
|
|
45
|
-
//
|
|
46
|
-
//
|
|
45
|
+
// Partial success: if only Ops was created, print it. Otherwise running the
|
|
46
|
+
// command again would create A DIFFERENT Ops topic, and the old id would be lost.
|
|
47
47
|
if (ops === null) {
|
|
48
48
|
log('Ops was not created — check: is the bot a group admin with "Manage topics", and are topics on?');
|
|
49
49
|
return;
|
package/package.json
CHANGED