@mikitasazan/notify 1.9.0 → 1.10.1

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.
@@ -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/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,102 @@
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
+ // Only when it IS the value of a row or a list item. The first shape of this
87
+ // rule read the whole card, so a deploy card quoting the words inside a
88
+ // commit body — the very commit that removed `all good` from the reports —
89
+ // raised a complaint about itself.
90
+ for (const row of rows) {
91
+ const value = row
92
+ .replace(/^•\s*/, '')
93
+ .replace(/^<b>[^<]*:<\/b>\s*/, '')
94
+ .replace(/<[^>]+>/g, '')
95
+ .trim();
96
+ if (/^all good$/i.test(value)) {
97
+ found.push('"all good" is a status, not a recommendation');
98
+ break;
99
+ }
100
+ }
101
+ return found;
102
+ };
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.
@@ -437,7 +450,7 @@ const renderDeploy = (e) => {
437
450
  // repeat in words, and it is the same law a job and a report follow. The
438
451
  // `Via` row is gone: it used to carry this same name one floor below.
439
452
  typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via, runUrl), runUrl),
440
- ...twoBlocks([field('Target', e.target), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
453
+ ...twoBlocks([field('Target', e.target), field('Reason', e.note)], [commitRow(e.commit, e.commitUrl, e.commitTitle), bodyQuote(e.commitBody)])
441
454
  ]);
442
455
  };
443
456
  const schedule = (expected, lastSeen, lastLabel) => {
@@ -519,7 +532,7 @@ const renderCi = (e) => {
519
532
  const runUrl = e.workflowUrl ?? e.url;
520
533
  return join([
521
534
  typeLine(icon, 'CI', mechanism(e.workflowName, undefined, runUrl), runUrl),
522
- ...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
535
+ ...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [commitRow(e.commit, e.commitUrl, e.commitTitle), bodyQuote(e.commitBody)])
523
536
  ]);
524
537
  };
525
538
  // A pull request and an issue are identified the way GitHub itself identifies
package/dist/send.js CHANGED
@@ -1,24 +1,27 @@
1
1
  /**
2
- * Транспорт. Переносит проверенный на проде код из
3
- * game-publisher/scripts/lib/telegram.ts (fetch 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
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 };
@@ -140,10 +148,11 @@ const deliver = async (where, text) => {
140
148
  log('skipped: no OPS_BOT_TOKEN, the message was not sent');
141
149
  return 'skipped';
142
150
  }
143
- // Токен 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,5 +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));
320
+ const html = render(e);
321
+ // Send first, complain second: the delivery of the real card must not wait
322
+ // on, or be lost to, a check about how it looks.
323
+ const result = await deliver(targets(e), html);
324
+ const faults = lintCard(html);
325
+ if (faults.length > 0) {
326
+ await reportBrokenCard(e, faults);
327
+ }
328
+ return result;
287
329
  };
package/dist/setup.js CHANGED
@@ -1,16 +1,16 @@
1
1
  /**
2
- * `notify setup "<Название проекта>"` заводит вкладки «⚙️ 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.9.0",
3
+ "version": "1.10.1",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",