@mikitasazan/notify 1.8.1 → 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/README.md +7 -6
- package/dist/cli-flags.d.ts +7 -7
- package/dist/cli-flags.js +8 -8
- package/dist/events.js +7 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lint.d.ts +20 -0
- package/dist/lint.js +90 -0
- package/dist/render.d.ts +14 -9
- package/dist/render.js +61 -45
- package/dist/send.d.ts +1 -19
- package/dist/send.js +109 -98
- package/dist/setup.js +13 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -95,17 +95,18 @@ notify report --project playhub --json < payload.json # весь объект
|
|
|
95
95
|
|
|
96
96
|
Полные сигнатуры — `src/events.ts`.
|
|
97
97
|
|
|
98
|
-
**Ключ задачи.** Первая строка каждой карточки —
|
|
98
|
+
**Ключ задачи.** Первая строка каждой карточки — три тега: `#тип #ключ
|
|
99
|
+
#итог`.
|
|
99
100
|
По ним дневной разборщик сверяет «это 🔴 уже закрыто более поздней карточкой
|
|
100
101
|
той же задачи?» без сравнения человеческих формулировок. Явный `--key`
|
|
101
102
|
побеждает; без него ключ выводится из заголовка и меняется вместе с ним —
|
|
102
103
|
регулярный отправитель передаёт `--key` явно. В stdout CLI ключ не попадает.
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
**Свободного HTML в пакете нет.** Дверь `sendReport()` удалена 25.08.2026:
|
|
106
|
+
она отдавала произвольную разметку и ставила карточке два тега вместо трёх —
|
|
107
|
+
единственный вид, который нельзя было отфильтровать по итогу. Последний её
|
|
108
|
+
отправитель перешёл на типизированный `report` ещё 25.08; тип события задаёт
|
|
109
|
+
каждый знак карточки, других путей нет.
|
|
109
110
|
|
|
110
111
|
**Неизвестный проект** не роняет вызвавший крон (код возврата 0), но больше и
|
|
111
112
|
не исчезает молча: в mac-config Ops уходит красная карточка «notify: событие
|
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/events.js
CHANGED
|
@@ -68,7 +68,11 @@ export const iconFor = (e) => {
|
|
|
68
68
|
case 'ci':
|
|
69
69
|
return e.status === 'ok' ? ICON.ok : ICON.red;
|
|
70
70
|
case 'job':
|
|
71
|
-
|
|
71
|
+
// `?? ICON.unknown`: an untyped `--json` payload can carry a status
|
|
72
|
+
// outside the map, and an undefined icon printed the literal word
|
|
73
|
+
// `undefined` at the head of line 2. Not knowing is itself a state the
|
|
74
|
+
// package already has a word and a sound for.
|
|
75
|
+
return JOB_ICON[e.status] ?? ICON.unknown;
|
|
72
76
|
case 'session':
|
|
73
77
|
return e.status === 'ok' ? ICON.ok : ICON.alarm;
|
|
74
78
|
case 'incident':
|
|
@@ -76,9 +80,9 @@ export const iconFor = (e) => {
|
|
|
76
80
|
case 'heartbeat_miss':
|
|
77
81
|
return e.recovered ? ICON.ok : ICON.unknown;
|
|
78
82
|
case 'pr':
|
|
79
|
-
return PR_ICON[e.action];
|
|
83
|
+
return PR_ICON[e.action] ?? ICON.unknown;
|
|
80
84
|
case 'issue':
|
|
81
|
-
return ISSUE_ICON[e.action];
|
|
85
|
+
return ISSUE_ICON[e.action] ?? ICON.unknown;
|
|
82
86
|
case 'report':
|
|
83
87
|
return ICON.info;
|
|
84
88
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { EventType, NotifyEvent, Project } from './events.ts';
|
|
2
2
|
export { severity } from './events.ts';
|
|
3
|
-
export { notify
|
|
3
|
+
export { notify } from './send.ts';
|
|
4
4
|
export { render } from './render.ts';
|
|
5
5
|
export { trend } from './trend.ts';
|
|
6
6
|
export type { SendResult } from './send.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { severity } from "./events.js";
|
|
2
|
-
export { notify
|
|
2
|
+
export { notify } from "./send.js";
|
|
3
3
|
// `render` is exported so a sender can put on disk EXACTLY the card that was
|
|
4
4
|
// sent rather than its own second version of the text. A hand-built copy had
|
|
5
5
|
// already drifted from what went out.
|
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.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* separates BLOCKS BY MEANING (header / body / actions), not mechanically
|
|
20
20
|
* after every line.
|
|
21
21
|
*/
|
|
22
|
-
import { type NotifyEvent } from './events.ts';
|
|
22
|
+
import { ICON, type NotifyEvent } from './events.ts';
|
|
23
23
|
/** Escapes EVERYTHING that comes from outside — only the template adds tags. */
|
|
24
24
|
export declare const esc: (v: unknown) => string;
|
|
25
25
|
/**
|
|
@@ -65,16 +65,20 @@ export declare const eventKey: (e: NotifyEvent) => string;
|
|
|
65
65
|
* `#fail` under a 🚫 and said so. Nor is a task that has simply gone quiet —
|
|
66
66
|
* nobody knows yet whether it broke, and `#unknown` is the honest word for it.
|
|
67
67
|
*/
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
/** Every icon the package can print — the key space of the outcome table. */
|
|
69
|
+
type Icon = (typeof ICON)[keyof typeof ICON];
|
|
70
|
+
/** The five words the third tag is allowed to be, and there is no sixth. */
|
|
71
|
+
type OutcomeTag = 'ok' | 'fail' | 'off' | 'unknown' | 'news';
|
|
70
72
|
/**
|
|
71
|
-
* The
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
73
|
+
* The type is `Record` over EVERY icon, not over `string`. A new icon added to
|
|
74
|
+
* `ICON` without a word here now fails to compile. Under the old loose type it
|
|
75
|
+
* fell through a `?? 'news'` default instead: a card whose outcome nobody had
|
|
76
|
+
* decided was indistinguishable from a card that is genuinely just news, and
|
|
77
|
+
* nothing anywhere went red. `news` is therefore written out for each icon
|
|
78
|
+
* that means it, never left to a fallback.
|
|
76
79
|
*/
|
|
77
|
-
export declare const
|
|
80
|
+
export declare const OUTCOME_TAG: Readonly<Record<Icon, OutcomeTag>>;
|
|
81
|
+
export declare const outcomeTag: (e: NotifyEvent) => OutcomeTag;
|
|
78
82
|
/**
|
|
79
83
|
* Renders an event into finished HTML text, cut to Telegram's limit.
|
|
80
84
|
* Tags are the FIRST line, added before the cut (not after, as before):
|
|
@@ -83,3 +87,4 @@ export declare const reportTags: (key: string) => string;
|
|
|
83
87
|
* parser on exactly the longest, meaning the most important, messages.
|
|
84
88
|
*/
|
|
85
89
|
export declare const render: (e: NotifyEvent) => string;
|
|
90
|
+
export {};
|
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.
|
|
@@ -383,10 +396,20 @@ const typeLine = (icon, type, action, url, aside) => {
|
|
|
383
396
|
// template prints the word "null". That is how line 2 of a card became
|
|
384
397
|
// `ℹ️ null` — reachable through `--json` and through a direct call from JS,
|
|
385
398
|
// where there are no types.
|
|
386
|
-
// `action || 'open'` in the linked case: an empty title must not swallow the
|
|
387
|
-
// link, which would be the one thing the card cannot afford to lose.
|
|
388
|
-
const line = url ? fieldLink(type, url, action || 'open') : field(type, action);
|
|
389
399
|
const tail = aside ? ` (${esc(aside)})` : '';
|
|
400
|
+
const name = action?.trim();
|
|
401
|
+
// No name: the card must not invent one. It used to write the word `open`
|
|
402
|
+
// into the identifier slot, so a report with a link and no title arrived as
|
|
403
|
+
// `Report: open` — and a deploy with neither fell back to its own status
|
|
404
|
+
// word, `Deploy: fail`, saying the outcome a third time after the icon and
|
|
405
|
+
// the tag. Both are the same mistake: a slot that must hold a name holding
|
|
406
|
+
// something else instead. The type word itself takes the link, so nothing
|
|
407
|
+
// clickable is lost and nothing false is said.
|
|
408
|
+
if (!name) {
|
|
409
|
+
const bare = `<b>${esc(cap(type))}</b>`;
|
|
410
|
+
return `${icon} ${url ? `<a href="${esc(url)}">${bare}</a>` : bare}${tail}`;
|
|
411
|
+
}
|
|
412
|
+
const line = url ? fieldLink(type, url, name) : field(type, name);
|
|
390
413
|
return line === null ? `${icon} <b>${esc(cap(type))}</b>${tail}` : `${icon} ${line}${tail}`;
|
|
391
414
|
};
|
|
392
415
|
// `workflowUrl ?? url`: half the senders send the run link under the name
|
|
@@ -405,7 +428,7 @@ const typeLine = (icon, type, action, url, aside) => {
|
|
|
405
428
|
* GitHub Action always fills the workflow name, and the hand-run scripts send
|
|
406
429
|
* no run link at all.
|
|
407
430
|
*/
|
|
408
|
-
const mechanism = (workflowName, via, runUrl) => workflowName ?? via
|
|
431
|
+
const mechanism = (workflowName, via, runUrl) => workflowName ?? via;
|
|
409
432
|
// The name of what ran sits WITH the type line, not eight lines below it.
|
|
410
433
|
// `Deploy: fail` and `by what means it ran` answer one question, and the owner
|
|
411
434
|
// read the two rows as unrelated things. It used to be one fact split in two:
|
|
@@ -426,8 +449,8 @@ const renderDeploy = (e) => {
|
|
|
426
449
|
// is already said by the icon and the third tag; there is nothing to
|
|
427
450
|
// repeat in words, and it is the same law a job and a report follow. The
|
|
428
451
|
// `Via` row is gone: it used to carry this same name one floor below.
|
|
429
|
-
typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via, runUrl)
|
|
430
|
-
...twoBlocks([field('Target', e.target), field('Reason', e.note)], [
|
|
452
|
+
typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via, runUrl), runUrl),
|
|
453
|
+
...twoBlocks([field('Target', e.target), field('Reason', e.note)], [commitRow(e.commit, e.commitUrl, e.commitTitle), bodyQuote(e.commitBody)])
|
|
431
454
|
]);
|
|
432
455
|
};
|
|
433
456
|
const schedule = (expected, lastSeen, lastLabel) => {
|
|
@@ -465,11 +488,11 @@ const renderJob = (e) => {
|
|
|
465
488
|
...(hasItems ? bullets(e.items, disabledList) : []),
|
|
466
489
|
e.command || e.logs ? '' : null,
|
|
467
490
|
fieldCode('Log', e.logs),
|
|
468
|
-
...fieldRun(e.command, e.commandNote)
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
491
|
+
...fieldRun(e.command, e.commandNote)
|
|
492
|
+
// No trailing `Workflow:` row. It pointed at `workflowUrl ?? url` — the
|
|
493
|
+
// exact address line 2 already carries — so it was one destination
|
|
494
|
+
// written twice, and it stood BELOW the `To do:` command, which is the
|
|
495
|
+
// last thing the card is supposed to say.
|
|
473
496
|
]);
|
|
474
497
|
};
|
|
475
498
|
const renderReport = (e) => {
|
|
@@ -508,8 +531,8 @@ const renderCi = (e) => {
|
|
|
508
531
|
const icon = iconFor(e);
|
|
509
532
|
const runUrl = e.workflowUrl ?? e.url;
|
|
510
533
|
return join([
|
|
511
|
-
typeLine(icon, 'CI', mechanism(e.workflowName, undefined, runUrl)
|
|
512
|
-
...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [
|
|
534
|
+
typeLine(icon, 'CI', mechanism(e.workflowName, undefined, runUrl), runUrl),
|
|
535
|
+
...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [commitRow(e.commit, e.commitUrl, e.commitTitle), bodyQuote(e.commitBody)])
|
|
513
536
|
]);
|
|
514
537
|
};
|
|
515
538
|
// A pull request and an issue are identified the way GitHub itself identifies
|
|
@@ -554,7 +577,7 @@ const renderIncident = (e) => join([
|
|
|
554
577
|
typeLine(iconFor(e), 'Incident', e.title, e.url),
|
|
555
578
|
e.detail && e.detail !== e.title ? note(e.detail) : null,
|
|
556
579
|
e.logs ? '' : null,
|
|
557
|
-
fieldCode('
|
|
580
|
+
fieldCode('Log', e.logs)
|
|
558
581
|
]);
|
|
559
582
|
// A session in trouble. Same law as every other card: identifier first, then
|
|
560
583
|
// the facts as fields, then his own words as a quote — never as a field, which
|
|
@@ -578,14 +601,16 @@ const renderSession = (e) => join([
|
|
|
578
601
|
]);
|
|
579
602
|
const renderHeartbeatMiss = (e) => {
|
|
580
603
|
const icon = iconFor(e);
|
|
581
|
-
const action = e.recovered ? 'ok' : 'miss';
|
|
582
604
|
return join([
|
|
583
605
|
// The task's name on the type line, exactly as a job card carries it. The
|
|
584
606
|
// `Task:` row said the same thing a floor below. No sender in any
|
|
585
607
|
// repository builds this event any more — the silence watchdog sends an
|
|
586
608
|
// ordinary job with `--status silent` — but a machine still running the old
|
|
587
609
|
// copy of that watchdog can, and the card it gets must obey the template.
|
|
588
|
-
|
|
610
|
+
// No bracket saying `ok` or `miss`: the icon says it, the third tag says
|
|
611
|
+
// it, and `miss` is not one of the five words the outcome is allowed to
|
|
612
|
+
// be. The bracket is for what finishes the NAME, never for a verdict.
|
|
613
|
+
typeLine(icon, 'Heartbeat', e.job),
|
|
589
614
|
field('Reason', e.note),
|
|
590
615
|
...schedule(e.expected, e.lastSeen, e.recovered ? 'Last run' : 'Last seen')
|
|
591
616
|
]);
|
|
@@ -660,24 +685,19 @@ export const eventKey = (e) => {
|
|
|
660
685
|
return `i${e.number}`;
|
|
661
686
|
}
|
|
662
687
|
};
|
|
663
|
-
|
|
688
|
+
// An empty instance tag (`#session # #fail`) is not a tag: it groups
|
|
689
|
+
// nothing and the parser cannot pair a red card with its green one. An
|
|
690
|
+
// untyped `--json` payload can leave every field it is derived from blank,
|
|
691
|
+
// so the project name is the last resort.
|
|
692
|
+
return (e.key ? slug(e.key) : fallback()) || slug(e.project) || 'event';
|
|
664
693
|
};
|
|
665
694
|
/**
|
|
666
|
-
* The
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
* The value comes from the ICON, never from the status word. The icon is
|
|
673
|
-
* already the single source of truth for the sound, and a second list of "what
|
|
674
|
-
* counts as broken" would drift from the first — it already did once, when a
|
|
675
|
-
* red card arrived silent.
|
|
676
|
-
*
|
|
677
|
-
* One icon meaning, one tag. A watchdog that SWITCHED SOMETHING OFF is not a
|
|
678
|
-
* failure and must not be filed under the same word as one: the owner read
|
|
679
|
-
* `#fail` under a 🚫 and said so. Nor is a task that has simply gone quiet —
|
|
680
|
-
* nobody knows yet whether it broke, and `#unknown` is the honest word for it.
|
|
695
|
+
* The type is `Record` over EVERY icon, not over `string`. A new icon added to
|
|
696
|
+
* `ICON` without a word here now fails to compile. Under the old loose type it
|
|
697
|
+
* fell through a `?? 'news'` default instead: a card whose outcome nobody had
|
|
698
|
+
* decided was indistinguishable from a card that is genuinely just news, and
|
|
699
|
+
* nothing anywhere went red. `news` is therefore written out for each icon
|
|
700
|
+
* that means it, never left to a fallback.
|
|
681
701
|
*/
|
|
682
702
|
export const OUTCOME_TAG = {
|
|
683
703
|
[ICON.red]: 'fail',
|
|
@@ -686,20 +706,16 @@ export const OUTCOME_TAG = {
|
|
|
686
706
|
[ICON.unknown]: 'unknown',
|
|
687
707
|
[ICON.ok]: 'ok',
|
|
688
708
|
[ICON.landed]: 'ok',
|
|
689
|
-
[ICON.approved]: 'ok'
|
|
709
|
+
[ICON.approved]: 'ok',
|
|
710
|
+
// Something happened; no verdict was passed on it.
|
|
711
|
+
[ICON.fresh]: 'news',
|
|
712
|
+
[ICON.taken]: 'news',
|
|
713
|
+
[ICON.discarded]: 'news',
|
|
714
|
+
[ICON.changes]: 'news',
|
|
715
|
+
[ICON.info]: 'news'
|
|
690
716
|
};
|
|
691
|
-
|
|
692
|
-
// digest — is news: something happened, no verdict was passed.
|
|
693
|
-
export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)] ?? 'news';
|
|
717
|
+
export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)];
|
|
694
718
|
const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
|
|
695
|
-
/**
|
|
696
|
-
* The tag line for free-form HTML (`sendReport`). The tag is the owner's
|
|
697
|
-
* FILTER, and it has nothing to do with the body's format: a daily report
|
|
698
|
-
* stays free-form text, but stops being the one card with no tags. The key
|
|
699
|
-
* used to hang off the tail as `<i><code>#key</code></i>` — that is the old
|
|
700
|
-
* format, from before tags moved to the first line.
|
|
701
|
-
*/
|
|
702
|
-
export const reportTags = (key) => `#report #${esc(slug(key))}`;
|
|
703
719
|
/**
|
|
704
720
|
* Renders an event into finished HTML text, cut to Telegram's limit.
|
|
705
721
|
* Tags are the FIRST line, added before the cut (not after, as before):
|
package/dist/send.d.ts
CHANGED
|
@@ -1,21 +1,3 @@
|
|
|
1
|
-
import type { NotifyEvent
|
|
1
|
+
import type { NotifyEvent } from './events.ts';
|
|
2
2
|
export type SendResult = 'sent' | 'skipped' | 'failed';
|
|
3
3
|
export declare const notify: (e: NotifyEvent) => Promise<SendResult>;
|
|
4
|
-
/**
|
|
5
|
-
* Готовый HTML во вкладку «Ops» проекта — ТОЛЬКО для дневных отчётов.
|
|
6
|
-
*
|
|
7
|
-
* Зачем исключение из правила «свободного текста в API нет». Отчёт — это не
|
|
8
|
-
* событие: в нём плотная строка вроде
|
|
9
|
-
* `🎮 1284 игр (🍎 412 iOS +3 · 🤖 890 Android +5) | 📈 +240 запусков`,
|
|
10
|
-
* и разложить её в `label=value` можно только испортив. Но транспорт у отчёта
|
|
11
|
-
* ТОТ ЖЕ: ретраи, 429, таймауты, curl-фолбэк, номер вкладки. Пока его копировали
|
|
12
|
-
* в каждый скрипт, один и тот же баг с дублями на таймауте пришлось чинить
|
|
13
|
-
* дважды — в пакете и в game-publisher (27.07.2026).
|
|
14
|
-
*
|
|
15
|
-
* Граница: формат событий по-прежнему задаёт только пакет, «своё» уведомление
|
|
16
|
-
* о деплое или упавшей задаче написать нельзя. Здесь стандартизирован транспорт,
|
|
17
|
-
* а не формат — и в этом весь смысл.
|
|
18
|
-
*
|
|
19
|
-
* Всегда беззвучно: отчёт читают утром, а не по звонку.
|
|
20
|
-
*/
|
|
21
|
-
export declare const sendReport: (project: Project, html: string, key?: string) => Promise<SendResult>;
|
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
|
-
import {
|
|
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 };
|
|
@@ -133,17 +141,18 @@ const sendOne = async (token, target, text) => {
|
|
|
133
141
|
log('out of send attempts');
|
|
134
142
|
return 'failed';
|
|
135
143
|
};
|
|
136
|
-
/**
|
|
144
|
+
/** The shared tail of `notify()`: token, targets, delivery one after another. */
|
|
137
145
|
const deliver = async (where, text) => {
|
|
138
146
|
const token = process.env.OPS_BOT_TOKEN?.trim();
|
|
139
147
|
if (!token) {
|
|
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,36 +317,13 @@ export const notify = async (e) => {
|
|
|
283
317
|
if (e.path) {
|
|
284
318
|
return sendFile(e);
|
|
285
319
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
* `🎮 1284 игр (🍎 412 iOS +3 · 🤖 890 Android +5) | 📈 +240 запусков`,
|
|
294
|
-
* и разложить её в `label=value` можно только испортив. Но транспорт у отчёта
|
|
295
|
-
* ТОТ ЖЕ: ретраи, 429, таймауты, curl-фолбэк, номер вкладки. Пока его копировали
|
|
296
|
-
* в каждый скрипт, один и тот же баг с дублями на таймауте пришлось чинить
|
|
297
|
-
* дважды — в пакете и в game-publisher (27.07.2026).
|
|
298
|
-
*
|
|
299
|
-
* Граница: формат событий по-прежнему задаёт только пакет, «своё» уведомление
|
|
300
|
-
* о деплое или упавшей задаче написать нельзя. Здесь стандартизирован транспорт,
|
|
301
|
-
* а не формат — и в этом весь смысл.
|
|
302
|
-
*
|
|
303
|
-
* Всегда беззвучно: отчёт читают утром, а не по звонку.
|
|
304
|
-
*/
|
|
305
|
-
export const sendReport = async (project, html, key) => {
|
|
306
|
-
if (!Object.hasOwn(ROUTES, project)) {
|
|
307
|
-
// Та же красная карточка, что у notify(): дверь свободного HTML — не
|
|
308
|
-
// лазейка для молчаливой потери (этот класс уже жил неделями дважды).
|
|
309
|
-
await reportLostProject(project, 'report');
|
|
310
|
-
return 'skipped';
|
|
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);
|
|
311
327
|
}
|
|
312
|
-
|
|
313
|
-
// Без проекта — как и render.ts: карточка уже лежит в форуме своего
|
|
314
|
-
// проекта, дублировать его в теге незачем. Строка тегов ПЕРВАЯ и до обрезки,
|
|
315
|
-
// как у любой другой карточки.
|
|
316
|
-
const tags = reportTags(key ?? 'daily');
|
|
317
|
-
return deliver([{ chat: forum.chat, thread: forum.ops, silent: true }], `${tags}\n${clampMessage(html, Math.max(64, 4000 - tags.length - 1))}`);
|
|
328
|
+
return result;
|
|
318
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