@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 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
- **Единственная дверь для свободного HTML** `sendReport()` (`report --json` у
105
- аналитик): плотную строку дневного отчёта не разложить в `label=value` не
106
- испортив. Стандартизирован транспорт, формат тела нет, и только здесь.
107
- Строку тегов `#report #ключ` пакет ставит и здесь: тег — фильтр владельца, к
108
- формату тела он отношения не имеет. Без явного ключа берётся `daily`.
105
+ **Свободного HTML в пакете нет.** Дверь `sendReport()` удалена 25.08.2026:
106
+ она отдавала произвольную разметку и ставила карточке два тега вместо трёх —
107
+ единственный вид, который нельзя было отфильтровать по итогу. Последний её
108
+ отправитель перешёл на типизированный `report` ещё 25.08; тип события задаёт
109
+ каждый знак карточки, других путей нет.
109
110
 
110
111
  **Неизвестный проект** не роняет вызвавший крон (код возврата 0), но больше и
111
112
  не исчезает молча: в mac-config Ops уходит красная карточка «notify: событие
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Каждое имя флага, которое CLI умеет читать. Существует ради одной вещи:
3
- * опечатка в имени флага раньше просто игнорировалась. `--noto=...` вместо
4
- * `--note=...` рисовал карточку без причины и выходил нулёмвладелец получал
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
- * имена, которые читает `one`/`num`/`pairs`, и требует, чтобы каждое было
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
- * Каждое имя флага, которое CLI умеет читать. Существует ради одной вещи:
3
- * опечатка в имени флага раньше просто игнорировалась. `--noto=...` вместо
4
- * `--note=...` рисовал карточку без причины и выходил нулёмвладелец получал
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
- * имена, которые читает `one`/`num`/`pairs`, и требует, чтобы каждое было
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
- return JOB_ICON[e.status];
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, sendReport } from './send.ts';
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, sendReport } from "./send.js";
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
- export declare const OUTCOME_TAG: Readonly<Record<string, string>>;
69
- export declare const outcomeTag: (e: NotifyEvent) => string;
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 tag line for free-form HTML (`sendReport`). The tag is the owner's
72
- * FILTER, and it has nothing to do with the body's format: a daily report
73
- * stays free-form text, but stops being the one card with no tags. The key
74
- * used to hang off the tail as `<i><code>#key</code></i>` that is the old
75
- * format, from before tags moved to the first line.
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 reportTags: (key: string) => string;
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
- const titleField = (title) => field('Title', title);
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 ?? (runUrl ? 'the run' : undefined);
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) ?? e.status, runUrl),
430
- ...twoBlocks([field('Target', e.target), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
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
- // Kept only when the caller actually names the workflowa named row is a
470
- // second, different destination; an unnamed one repeats the Task link.
471
- e.workflowName && (e.workflowUrl ?? e.url) ? '' : null,
472
- e.workflowName ? fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName) : null
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) ?? e.status, runUrl),
512
- ...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
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('Logs', e.logs)
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
- typeLine(icon, 'Heartbeat', e.job, undefined, action),
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
- return e.key ? slug(e.key) : fallback();
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 third tag is the OUTCOME, and it is always there. The owner: "I'm
667
- * missing a fail tag or something like it, so failures can be grouped and
668
- * ok can be grouped." One tap in Telegram collects every failure of a
669
- * project at once, no matter what type it arrived as — a deploy, a check,
670
- * a scheduled task, an incident.
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
- // Everything left over a task was opened, a PR opened, edits asked for, a
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, Project } from './events.ts';
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 curl-фолбэк через stdin,
4
- * `.trim()` токена) и добавляет то, чего там не было: несколько целей за
5
- * вызов, `message_thread_id`, повтор на HTTP 429 с уважением `retry_after`,
6
- * повтор на 5xx, отказ без повтора на прочих 4xx.
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
- * Токен ТОЛЬКО из `process.env.OPS_BOT_TOKEN`, с `.trim()`: перевод
9
- * строки в токене (частая находка при копипасте) заставляет curl разобрать
10
- * конфиг как две директивы и утащить хвост токена в stderr прогона.
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
- * Нет токена 'skipped', не исключение: уведомление не имеет права уронить
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 { clampMessage, render, reportTags } from "./render.js";
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, не stdout — stdout зарезервирован под возможный машинный вывод CLI.
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
- * Запасной путь: curl другой стек TLS/DNS, выручает там, где fetch/undici
35
- * не маршрутизирует. URL с токеном уходит файлом конфига через stdin, а не
36
- * аргументом: в argv его видно любому пользователю сервера через `ps aux`.
37
- * stderr в 'pipe', а не наследуется: сообщение об ошибке curl может
38
- * содержать кусок URL с токеном, в лог прогона он попадать не должен.
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 собственный таймаут curl (`max-time` выше). Как и таймаут fetch, он
59
- // означает «ответа нет», а не «не доставлено»: повтор положил бы в чат
60
- // вторую копию. Всё остальное (отказ соединения, 4xx с `fail`) повторить
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 кроме 429 постоянная ошибка (не тот thread, бот не админ,
85
- // неверный chat_id). Повтор её не исправит.
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
- // Причину обязательно вытаскиваем: Telegram кладёт её в `description`
88
- // («message thread not found», «can't parse entities»), и без неё понять,
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
- // не успеть вернуться. Повтор (хоть curl-ом, хоть следующей попыткой) кладёт
98
- // в чат второй экземпляр того же сообщения дедупа у Bot API нет. Поэтому
99
- // на таймауте останавливаемся и честно пишем 'failed': лишняя копия аварии
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
- // Сюда попадают отказы соединения (DNS, TLS, сеть недоступна) — запрос
106
- // почти наверняка не ушёл, и curl-фолбэк безопасен. НЕ абсолютно: обрыв
107
- // (reset/truncation) ПОСЛЕ того, как Telegram принял POST, тоже кидает
108
- // исключение тогда повтор даст дубль. Это редкий случай; полное
109
- // отсутствие дублей невозможно без idempotency-key у Bot API (его нет).
110
- // Логика прежняя: дубль на редком reset меньшее зло, чем потеря
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
- /** Общий хвост для `notify` и `sendReport`: токен, цели, последовательная отправка. */
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
- // Токен interpolируется в URL и в curl-конфиг (`url = "...bot${token}..."`).
144
- // Валидный токен Telegram это `\d+:[\w-]+`; что-либо с кавычкой/переводом
145
- // строки/`?` сломало бы разбор (инъекция директивы curl или query-хвост).
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
- // Последовательно, не Promise.all: провал одной цели не должен гонять
156
- // ретраи параллельно с остальными и колотить API по нескольким чатам разом.
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
- * Файл (sendDocument) multipart, поэтому не через `buildBody`. Политика
164
- * повторов та же, что у сообщений: 429 с уважением retry_after, 5xx —
165
- * повтор, прочие 4xx и таймаут нет (дубль файла хуже пропуска).
166
- * Отдельного curl-фолбэка нет: файл шлётся с этой же машины, а не из CI,
167
- * и другой TLS-стек здесь ни разу не понадобился.
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, не openAsBlob: тот появился в Node 19.8, а пакет
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
- // Контракт тот же, что у deliver: провал одной цели не отменяет остальные.
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
- * отменяет остальные. `'sent'`, если хотя бы одна цель получила сообщение.
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
- * меняется) но и не исчезает молча: в mac-config Ops уходит красная
257
- * карточка. Дважды этот класс провала жил незамеченным неделями: «vault» и
258
- * «mac-config» до 04.08, отчёты Alitools до 18.08. Рекурсия невозможна:
259
- * карточка-ошибка адресована mac-config, который в ROUTES есть всегда.
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, не `in`: `in` ходит по цепочке прототипов, и --project
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
- return deliver(targets(e), render(e));
287
- };
288
- /**
289
- * Готовый HTML во вкладку «Ops» проекта — ТОЛЬКО для дневных отчётов.
290
- *
291
- * Зачем исключение из правила «свободного текста в API нет». Отчёт — это не
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
- const forum = ROUTES[project];
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 "<Название проекта>"` заводит вкладки «⚙️ Ops» и «💬 Dev»
3
- * в уже созданном форуме и печатает готовую строку для `ROUTES`.
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
- * Сам форум-супергруппу бот создать не может — Telegram разрешает это только
6
- * живому аккаунту. Поэтому порядок для нового проекта такой:
7
- * 1. создать группу в Telegram, включить в ней «Темы», добавить
8
- * @mikita_ops_bot администратором с правом «Управление темами»;
9
- * 2. `notify setup <chat_id>` — заведёт обе вкладки и напечатает строку;
10
- * 3. вставить строку в `src/routes.ts`.
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
- * Шаг 1 делается один раз на проект и занимает полминуты; шаги 23
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
- // Сеть/таймаут/нечитаемый ответне роняем CLI (его контракт: всегда exit 0).
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
- // Частичный успех: если создался только Ops печатаем его, иначе повторный
46
- // запуск создал бы ДРУГУЮ тему Ops, а старый id потерялся бы.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.8.1",
3
+ "version": "1.10.0",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",