@mikitasazan/notify 1.3.0 → 1.4.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.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Каждое имя флага, которое CLI умеет читать. Существует ради одной вещи:
3
+ * опечатка в имени флага раньше просто игнорировалась. `--noto=...` вместо
4
+ * `--note=...` рисовал карточку без причины и выходил нулём — владелец получал
5
+ * обрезанное сообщение, а вызывающая задача считала, что всё хорошо.
6
+ *
7
+ * Список закрытый и проверяется тестом: тест вытаскивает из этого же файла все
8
+ * имена, которые читает `one`/`num`/`pairs`, и требует, чтобы каждое было
9
+ * здесь. Разойтись молча он не может.
10
+ */
11
+ export declare const KNOWN_FLAGS: ReadonlySet<string>;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Каждое имя флага, которое CLI умеет читать. Существует ради одной вещи:
3
+ * опечатка в имени флага раньше просто игнорировалась. `--noto=...` вместо
4
+ * `--note=...` рисовал карточку без причины и выходил нулём — владелец получал
5
+ * обрезанное сообщение, а вызывающая задача считала, что всё хорошо.
6
+ *
7
+ * Список закрытый и проверяется тестом: тест вытаскивает из этого же файла все
8
+ * имена, которые читает `one`/`num`/`pairs`, и требует, чтобы каждое было
9
+ * здесь. Разойтись молча он не может.
10
+ */
11
+ export const KNOWN_FLAGS = new Set([
12
+ 'action', 'actor', 'assignee', 'author', 'body', 'branch', 'commit',
13
+ 'commit-body', 'commit-title', 'commit-url', 'detail', 'expected',
14
+ 'filename', 'item', 'job', 'key', 'last-seen', 'line', 'logs', 'note',
15
+ 'number', 'path', 'period', 'project', 'reviewer', 'stat', 'status',
16
+ 'target', 'title', 'url', 'via', 'workflow-name', 'workflow-url',
17
+ // Флаги без значения. Живут здесь же, чтобы разбор и список не разошлись.
18
+ 'json', 'recovered', 'dry-run'
19
+ ]);
package/dist/cli.js CHANGED
@@ -21,16 +21,30 @@
21
21
  * notify setup <chat_id форума> <ключ-проекта> # создать вкладки, см. setup.ts
22
22
  */
23
23
  import { readFileSync } from 'node:fs';
24
+ import { KNOWN_FLAGS } from "./cli-flags.js";
25
+ import { render } from "./render.js";
24
26
  import { notify } from "./send.js";
25
27
  import { setupTopic } from "./setup.js";
26
28
  const log = (msg) => console.error(`[notify] ${msg}`);
29
+ /**
30
+ * Anything taken from the caller and echoed into a message goes through this.
31
+ *
32
+ * `sent`, `failed` and `skipped` are a CONTRACT: `notify-fail.sh` greps
33
+ * `^\[notify\] sent$`, the server-side silence watchdog matches `*sent*`, and
34
+ * `action.yml` warns on `*failed*|*skipped*`. An input value carrying one of
35
+ * those words puts it on stderr — `notify sent --project=x` printed
36
+ * `unknown event type: sent`, and the watchdog read a card that was never
37
+ * delivered as delivered, then stopped repeating the alarm. Found by Codex,
38
+ * 25.08.2026.
39
+ */
40
+ const safe = (v) => String(v ?? '').replace(/\b(sent|failed|skipped)\b/gi, (w) => `${w[0]}·${w.slice(1)}`);
27
41
  const args = process.argv.slice(2);
28
42
  const command = args[0];
29
43
  if (command === 'setup') {
30
44
  const [, chatId, projectKey] = args;
31
45
  if (!chatId || !projectKey) {
32
- log('использование: notify setup <chat_id форума> <ключ-проекта>');
33
- log(' сначала создай группу, включи в ней «Темы» и добавь бота админом');
46
+ log('usage: notify setup <forum chat_id> <project key>');
47
+ log(' create the group first, turn Topics on in it, add the bot as admin');
34
48
  process.exit(0);
35
49
  }
36
50
  await setupTopic(chatId, projectKey);
@@ -39,11 +53,11 @@ if (command === 'setup') {
39
53
  const flags = new Map();
40
54
  const parseErrors = [];
41
55
  /** Флаги без значения. Всё остальное обязано его иметь. */
42
- const BOOLEAN_FLAGS = new Set(['json', 'recovered']);
56
+ const BOOLEAN_FLAGS = new Set(['json', 'recovered', 'dry-run']);
43
57
  for (let i = 1; i < args.length; i++) {
44
58
  const arg = args[i];
45
59
  if (!arg.startsWith('--')) {
46
- parseErrors.push(`лишний аргумент без флага: «${arg}»`);
60
+ parseErrors.push(`stray argument with no flag: "${safe(arg)}"`);
47
61
  continue;
48
62
  }
49
63
  // Форма `--key=value` обязательна для значений, начинающихся с `--`
@@ -65,19 +79,24 @@ for (let i = 1; i < args.length; i++) {
65
79
  // ТЕРЯЛОСЬ ВСЁ сообщение, а `--status` без значения рисовал 🔴 на
66
80
  // успешном деплое. Теперь это явная ошибка разбора.
67
81
  if (next === undefined || next.startsWith('--')) {
68
- parseErrors.push(`флаг --${key} без значения`);
82
+ parseErrors.push(`flag --${safe(key)} with no value`);
69
83
  continue;
70
84
  }
71
85
  i++;
72
86
  flags.set(key, [...(flags.get(key) ?? []), next]);
73
87
  }
74
88
  const one = (key) => flags.get(key)?.[0];
89
+ for (const key of flags.keys()) {
90
+ if (!KNOWN_FLAGS.has(key)) {
91
+ parseErrors.push(`unknown flag --${safe(key)}`);
92
+ }
93
+ }
75
94
  // Число с явной ошибкой разбора, иначе рендер рисовал «PR #NaN».
76
95
  const num = (key) => {
77
96
  const raw = one(key);
78
97
  const n = Number(raw);
79
98
  if (raw === undefined || Number.isNaN(n)) {
80
- parseErrors.push(`--${key}: ожидается число, получено «${raw ?? ''}»`);
99
+ parseErrors.push(`--${safe(key)}: expected a number, got "${safe(raw)}"`);
81
100
  return 0;
82
101
  }
83
102
  return n;
@@ -114,7 +133,7 @@ const ISSUE_ALIASES = {
114
133
  const prAction = (raw) => {
115
134
  const hit = PR_ALIASES[(raw ?? '').toLowerCase()];
116
135
  if (!hit) {
117
- parseErrors.push(`--action: неизвестное действие PR «${raw ?? ''}» (${Object.keys(PR_ALIASES).join(', ')})`);
136
+ parseErrors.push(`--action: unknown PR action "${safe(raw)}" (${Object.keys(PR_ALIASES).join(', ')})`);
118
137
  return 'opened';
119
138
  }
120
139
  return hit;
@@ -122,7 +141,7 @@ const prAction = (raw) => {
122
141
  const issueAction = (raw) => {
123
142
  const hit = ISSUE_ALIASES[(raw ?? '').toLowerCase()];
124
143
  if (!hit) {
125
- parseErrors.push(`--action: неизвестное действие задачи «${raw ?? ''}» (${Object.keys(ISSUE_ALIASES).join(', ')})`);
144
+ parseErrors.push(`--action: unknown issue action "${raw ?? ''}" (${Object.keys(ISSUE_ALIASES).join(', ')})`);
126
145
  return 'opened';
127
146
  }
128
147
  return hit;
@@ -157,7 +176,9 @@ if (flags.has('json')) {
157
176
  event = { ...payload, type: command };
158
177
  }
159
178
  catch (err) {
160
- log(`не удалось разобрать --json со stdin: ${err instanceof Error ? err.message : String(err)}`);
179
+ // Тоже в parseErrors: обе аналитики зовут CLI через `|| true`, и молчащий
180
+ // разбор JSON означал бы зелёный крон без дневного отчёта.
181
+ parseErrors.push(`--json from stdin did not parse: ${safe(err instanceof Error ? err.message : err)}`);
161
182
  }
162
183
  }
163
184
  else {
@@ -183,7 +204,7 @@ else {
183
204
  event = {
184
205
  type: 'job',
185
206
  project: project(),
186
- job: one('job') ?? '(без имени)',
207
+ job: one('job') ?? '(no name)',
187
208
  status: jobStatus(),
188
209
  stats: pairs('stat'),
189
210
  items: items(),
@@ -197,7 +218,7 @@ else {
197
218
  event = {
198
219
  type: 'report',
199
220
  project: project(),
200
- title: one('title') ?? '(без заголовка)',
221
+ title: one('title') ?? '(no title)',
201
222
  period: one('period'),
202
223
  lines: pairs('line'),
203
224
  items: items(),
@@ -215,6 +236,7 @@ else {
215
236
  commitTitle: one('commit-title'),
216
237
  commitBody: one('commit-body'),
217
238
  actor: one('actor'),
239
+ note: one('note'),
218
240
  workflowUrl: one('workflow-url'),
219
241
  workflowName: one('workflow-name'),
220
242
  url: one('url')
@@ -226,7 +248,8 @@ else {
226
248
  project: project(),
227
249
  action: prAction(one('action')),
228
250
  number: num('number'),
229
- title: one('title') ?? '(без заголовка)',
251
+ title: one('title') ?? '(no title)',
252
+ body: one('body'),
230
253
  author: one('author'),
231
254
  reviewer: one('reviewer'),
232
255
  url: one('url')
@@ -238,7 +261,7 @@ else {
238
261
  project: project(),
239
262
  action: issueAction(one('action')),
240
263
  number: num('number'),
241
- title: one('title') ?? '(без заголовка)',
264
+ title: one('title') ?? '(no title)',
242
265
  body: one('body'),
243
266
  author: one('author'),
244
267
  assignee: one('assignee'),
@@ -249,7 +272,7 @@ else {
249
272
  event = {
250
273
  type: 'incident',
251
274
  project: project(),
252
- title: one('title') ?? '(без заголовка)',
275
+ title: one('title') ?? '(no title)',
253
276
  detail: one('detail'),
254
277
  logs: one('logs'),
255
278
  url: one('url')
@@ -259,7 +282,7 @@ else {
259
282
  event = {
260
283
  type: 'heartbeat_miss',
261
284
  project: project(),
262
- job: one('job') ?? '(без имени)',
285
+ job: one('job') ?? '(no name)',
263
286
  lastSeen: one('last-seen'),
264
287
  expected: one('expected'),
265
288
  recovered: flags.has('recovered'),
@@ -269,12 +292,12 @@ else {
269
292
  case 'file': {
270
293
  const path = one('path');
271
294
  if (!path) {
272
- parseErrors.push('--path: обязателен для file');
295
+ parseErrors.push('--path: required for a file event');
273
296
  }
274
297
  event = {
275
298
  type: 'file',
276
299
  project: project(),
277
- title: one('title') ?? '(без заголовка)',
300
+ title: one('title') ?? '(no title)',
278
301
  path: path ?? '',
279
302
  filename: one('filename'),
280
303
  note: one('note')
@@ -282,7 +305,10 @@ else {
282
305
  break;
283
306
  }
284
307
  default:
285
- log(`неизвестный тип события: ${command ?? '(не указан)'}`);
308
+ // В parseErrors, а не просто в лог: иначе неизвестный тип уходил в
309
+ // тишину — событие не собиралось, ошибок разбора не было, и CLI выходил
310
+ // нулём, ничего не отправив и ничего об этом не сказав.
311
+ parseErrors.push(`unknown event type: ${safe(command ?? '(none given)')}`);
286
312
  }
287
313
  }
288
314
  // --key применим к любому типу — одна точка вместо строки в каждом case
@@ -297,7 +323,26 @@ if (parseErrors.length > 0) {
297
323
  for (const err of parseErrors) {
298
324
  log(err);
299
325
  }
300
- log('событие не отправлено исправь команду');
326
+ // The word `failed` is a CONTRACT, not prose. Two readers match on it: the
327
+ // GitHub Action turns it into a yellow annotation, and the VPS watchdog reads
328
+ // the combined stream for `sent|failed|skipped`. Before this, a parse error
329
+ // printed neither word — the run stayed green, the watchdog stayed quiet, and
330
+ // the card simply never existed.
331
+ // It must NOT contain the substring `sent`: heartbeat-check.sh matches `*sent*`
332
+ // and would read a failure as a success.
333
+ log('failed: bad command, nothing delivered');
334
+ process.exit(0);
335
+ }
336
+ if (event && flags.has('dry-run')) {
337
+ // The rendered card on STDOUT, nothing sent and no token needed. This is how
338
+ // a change to the format is shown to the owner before it reaches a forum, and
339
+ // how ~25 edited call sites are checked one by one — the package always exits
340
+ // 0, so a typo in a flag is otherwise silent.
341
+ //
342
+ // stdout, not stderr: every other line this CLI prints goes to stderr, and
343
+ // watchdogs read that stream for the words `sent|failed|skipped`. A card
344
+ // printed there would be read as a verdict.
345
+ process.stdout.write(`${render(event)}\n`);
301
346
  process.exit(0);
302
347
  }
303
348
  if (event) {
@@ -308,7 +353,9 @@ if (event) {
308
353
  log(await notify(event));
309
354
  }
310
355
  catch (err) {
311
- log(`не отправлено: ${err instanceof Error ? err.message : String(err)}`);
356
+ // Слово `failed` контракт, тот же, что у ошибки разбора выше. Без него
357
+ // исключение при отправке читалось сторожами как «ничего не случилось».
358
+ log(`failed: ${safe(err instanceof Error ? err.message : err)}`);
312
359
  }
313
360
  }
314
361
  process.exit(0);
package/dist/events.d.ts CHANGED
@@ -49,12 +49,12 @@ export type NotifyEvent = Keyed & (
49
49
  commit?: string;
50
50
  /** Ссылка на коммит — строка «коммит» становится кликабельной. */
51
51
  commitUrl?: string;
52
- /** Заголовок коммита — рендерится рядом с телом в цитате. */
52
+ /** Заголовок коммита — рендерится полем `Title:`, тело идёт цитатой ниже. */
53
53
  commitTitle?: string;
54
54
  /** Тело коммита, если есть — та же цитата, что и заголовок. */
55
55
  commitBody?: string;
56
56
  workflowUrl?: string;
57
- /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
57
+ /** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
58
58
  workflowName?: string;
59
59
  url?: string;
60
60
  /**
@@ -85,8 +85,13 @@ export type NotifyEvent = Keyed & (
85
85
  items?: Item[];
86
86
  note?: string;
87
87
  workflowUrl?: string;
88
- /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
88
+ /** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
89
89
  workflowName?: string;
90
+ /**
91
+ * Запасное имя для ссылки на прогон: половина отправителей шлёт её как
92
+ * `--url`. Рендер берёт `workflowUrl ?? url`, так что оба имени работают.
93
+ * В новых вызовах предпочитай `workflowUrl` — оно говорит, куда ведёт.
94
+ */
90
95
  url?: string;
91
96
  }
92
97
  /** Сводка с цифрами: дневной отчёт, дайджест аналитики. */
@@ -124,14 +129,19 @@ export type NotifyEvent = Keyed & (
124
129
  commit?: string;
125
130
  /** Ссылка на коммит — хэш становится кликабельным. */
126
131
  commitUrl?: string;
127
- /** Заголовок коммита (subject) — рендерится в цитате вместе с телом. */
132
+ /** Заголовок коммита (subject) — отдельное поле `Title:`, не цитата. */
128
133
  commitTitle?: string;
129
134
  /** Тело коммита (после subject) — та же цитата, что и заголовок. */
130
135
  commitBody?: string;
131
136
  actor?: string;
132
- /** Ссылка на прогон (workflow run) — отдельно от `url`, который у CI не используется. */
137
+ /**
138
+ * Why this run happened, when there is no commit to point at: a nightly
139
+ * schedule, a manual press. Renders as `Reason:`, same as on deploy.
140
+ */
141
+ note?: string;
142
+ /** Ссылка на прогон (workflow run) — отдельно от `url` — запасной для `workflowUrl`. */
133
143
  workflowUrl?: string;
134
- /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
144
+ /** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
135
145
  workflowName?: string;
136
146
  url?: string;
137
147
  }
@@ -146,6 +156,8 @@ export type NotifyEvent = Keyed & (
146
156
  action: 'opened' | 'ready_for_review' | 'review_requested' | 'approved' | 'changes_requested' | 'merged' | 'closed';
147
157
  number: number;
148
158
  title: string;
159
+ /** PR description — quoted on its own; the title is the `Title:` field above it. */
160
+ body?: string;
149
161
  author?: string;
150
162
  reviewer?: string;
151
163
  url?: string;
@@ -157,7 +169,7 @@ export type NotifyEvent = Keyed & (
157
169
  action: 'opened' | 'assigned' | 'closed';
158
170
  number: number;
159
171
  title: string;
160
- /** Тело задачи — рендерится в цитате вместе с заголовком. */
172
+ /** Тело задачи — цитата под полем `Title:`, отдельно от заголовка. */
161
173
  body?: string;
162
174
  author?: string;
163
175
  assignee?: string;
package/dist/render.js CHANGED
@@ -41,12 +41,32 @@ export const clampMessage = (text, limit = 4000) => {
41
41
  // blockquote — с приходом цитаты для примечаний/деталей длинный detail режется
42
42
  // прямо посередине неё, и без этого тега Telegram отвечал бы 400 на незакрытую
43
43
  // цитату (regex `<blockquote[ >]` ловит и вариант с атрибутом `expandable`).
44
- const tail = ['b', 'a', 'i', 'code', 'blockquote']
45
- .filter((t) => {
46
- const opened = (body.match(new RegExp(`<${t}[ >]`, 'g')) ?? []).length;
47
- const closed = (body.match(new RegExp(`</${t}>`, 'g')) ?? []).length;
48
- return opened > closed;
49
- })
44
+ // `u` в списке с 2026-08-25: заголовок группы рисуется как `<i><u>…</u></i>`,
45
+ // и обрезанный посередине длинный заголовок оставлял `<u>` незакрытым.
46
+ // Telegram отвечает на такое 400 то есть карточка пропадала целиком, а
47
+ // отправитель с `|| true` этого не замечал. Нашёл Codex; воспроизводится
48
+ // отчётом с именем группы в 5000 знаков.
49
+ //
50
+ // Порядок закрытия — обратный порядку ОТКРЫТИЯ, а не фиксированный список.
51
+ // Фиксированный список закрывал `<i><u>` как `</i></u>`: тегов поровну,
52
+ // счётчик сходится, вложенность нарушена, и Telegram отвечает тем же 400.
53
+ // Второй заход того же бага (25.08.2026), поэтому теперь порядок берётся из
54
+ // самого текста: последний открытый закрывается первым.
55
+ const open = [];
56
+ const tagRe = /<(\/?)(b|a|i|u|code|blockquote)[ >]/g;
57
+ for (let m = tagRe.exec(body); m !== null; m = tagRe.exec(body)) {
58
+ if (m[1] === '/') {
59
+ const at = open.lastIndexOf(m[2]);
60
+ if (at !== -1) {
61
+ open.splice(at, 1);
62
+ }
63
+ }
64
+ else {
65
+ open.push(m[2]);
66
+ }
67
+ }
68
+ const tail = open
69
+ .reverse()
50
70
  .map((t) => `</${t}>`)
51
71
  .join('');
52
72
  return `${body}${tail}\n…`;
@@ -79,14 +99,23 @@ const fieldLink = (label, url, text) => {
79
99
  if (text === undefined || text === null || text === '') {
80
100
  return null;
81
101
  }
82
- return url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text)}</a>` : field(label, text);
102
+ // `firstLine` here for the same reason `field` has it: the linked case used to
103
+ // skip it, so a multi-line value (arvent's two-line `commit`) became two-line
104
+ // LINK TEXT instead of one identifier.
105
+ return url
106
+ ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(firstLine(text))}</a>`
107
+ : field(label, text);
83
108
  };
84
109
  /**
85
110
  * Поле-действие (`workflow:`): в отличие от `fieldLink`, без URL это НЕ
86
111
  * поле — прогону просто некуда вести, показывать голое слово «run» без
87
112
  * ссылки бессмысленнее, чем не показывать строку вовсе.
88
113
  */
89
- const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'run')}</a>` : null;
114
+ // Текст ссылки имя того, куда она ведёт (имя workflow, имя прогона). Запасное
115
+ // слово было «run»: существительное, которое ничего не называет — владелец читал
116
+ // «Workflow: run» и не понимал, что это. «open» — глагол, он хотя бы честно
117
+ // говорит, что это ссылка, а не название.
118
+ const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'open')}</a>` : null;
90
119
  /** Моноширинное поле — путь/команда для копирования, не ссылка. */
91
120
  const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
92
121
  /** Заголовок группы: курсив + подчёркивание, без жирности, без двоеточия. */
@@ -120,56 +149,90 @@ const renderGroup = (g) => [
120
149
  ...g.items.map((it, i) => groupItem(it, i, false))
121
150
  ];
122
151
  /**
123
- * Цитата коммита/задачи: заголовок первой строкой (multiline title режется
124
- * до первой строкиsubject не должен тащить в цитату собственное тело
125
- * под-коммита), тело — через пустую строку, если есть.
152
+ * ОДНО правило на все карточки, где есть и заголовок, и тело: заголовок —
153
+ * обычное поле `Title:`, тело цитата, и в цитате больше ничего нет.
154
+ *
155
+ * Раньше заголовок клался В ЦИТАТУ вместе с телом, разделённые пустой
156
+ * строкой. Владелец нашёл, чем это плохо: заголовок — главное в карточке, то,
157
+ * ЧТО это, а лежал он серым текстом того же веса, что и описание, и отличить
158
+ * одно от другого можно было только по пустой строке. У PR без тела карточка
159
+ * вырождалась в одинокую серую цитату из одной строки.
160
+ *
161
+ * Заголовок режется до первой строки: многострочный subject коммита не должен
162
+ * затягивать в поле собственное тело.
126
163
  */
127
- const commitQuote = (title, body) => {
128
- if (!title && !body) {
129
- return null;
130
- }
131
- const text = [title ? firstLine(title) : undefined, body].filter(Boolean).join('\n\n');
132
- return note(text);
133
- };
164
+ const titleField = (title) => field('Title', title);
165
+ const bodyQuote = (body) => body ? note(body) : null;
134
166
  // Значок = статус сообщения, не тип события. Ровно четыре на весь пакет —
135
167
  // закреплённая легенда в форумах обещает это владельцу как факт, не как
136
168
  // приближение. 🔴 сломалось, 🚨 инцидент, ✅ прошло, ℹ️ к сведению.
137
169
  const ICON = { red: '🔴', alarm: '🚨', ok: '✅', info: 'ℹ️' };
138
170
  /** Строка 2: значок вне жирного, `<b>Тип:</b> действие` — то же поле, не особый случай. */
139
- const typeLine = (icon, type, action) => `${icon} ${field(type, action)}`;
171
+ // `action` объявлен строкой, но приходит и из `--json`, и из прямых вызовов на
172
+ // JS, где типов нет. Пустое или отсутствующее значение давало строку `ℹ️ null`
173
+ // прямо во второй строке карточки. Пустая строка честнее: поле просто исчезает.
174
+ const typeLine = (icon, type, action) => {
175
+ // `field` возвращает null на пустом значении, а интерполяция null в шаблон
176
+ // печатает слово «null». Так вторая строка карточки становилась `ℹ️ null` —
177
+ // достижимо через `--json` и прямой вызов на JS, где типов нет.
178
+ const line = field(type, action);
179
+ return line === null ? `${icon} <b>${esc(cap(type))}</b>` : `${icon} ${line}`;
180
+ };
181
+ // `workflowUrl ?? url`: половина отправителей шлёт ссылку на прогон под именем
182
+ // `--url` — это имя было в пакете раньше и осталось в вызовах. Рендер читал
183
+ // только `workflowUrl`, поэтому красная карточка приходила БЕЗ ЕДИНОЙ ССЫЛКИ
184
+ // на логи. Отвергать `--url` было бы честнее по имени и хуже по делу: намерение
185
+ // однозначно, а карточка без ссылки бесполезна ровно тогда, когда нужна.
140
186
  const renderDeploy = (e) => {
141
187
  const icon = e.status === 'ok' ? ICON.ok : ICON.red;
142
188
  return join([
143
189
  typeLine(icon, 'Deploy', e.status),
144
190
  '',
145
191
  fieldLink('Commit', e.commitUrl, e.commit),
146
- commitQuote(e.commitTitle, e.commitBody),
192
+ titleField(e.commitTitle),
193
+ bodyQuote(e.commitBody),
147
194
  field('Via', e.via),
148
195
  field('Target', e.target),
149
196
  field('Reason', e.note),
150
- e.workflowUrl ? '' : null,
151
- fieldAction('Workflow', e.workflowUrl, e.workflowName)
197
+ e.workflowUrl ?? e.url ? '' : null,
198
+ fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
152
199
  ]);
153
200
  };
154
201
  const renderJob = (e) => {
155
202
  const icon = e.status === 'fail' || e.status === 'disabled' ? ICON.red : ICON.ok;
156
203
  const hasItems = (e.items ?? []).length > 0;
204
+ const disabledList = hasItems && e.status === 'disabled';
157
205
  return join([
158
206
  typeLine(icon, 'Job', e.status),
159
207
  '',
208
+ // The name is NOT the type line: line 2 is `Job: fail` by the format's own
209
+ // rule, so the name is its own field. Called `Task:` and not `Job:` because
210
+ // repeating the label of the line right above it reads as a mistake.
211
+ // Until now the name was dropped entirely — every caller passed it and the
212
+ // owner only ever saw it as the small grey instance tag.
213
+ field('Task', e.job),
160
214
  field('Reason', e.note),
161
215
  ...(e.stats ?? []).map(([label, value]) => field(label, value)),
162
216
  hasItems ? '' : null,
163
- hasItems ? group('Disabled workflows') : null,
164
- ...(hasItems && e.status === 'disabled' ? bullets(e.items, true) : hasItems ? bullets(e.items, false) : []),
165
- e.workflowUrl ? '' : null,
166
- fieldAction('Workflow', e.workflowUrl, e.workflowName)
217
+ // Heading ONLY for `disabled`. It used to print for any job carrying a
218
+ // list, so playhub's daily card of newly published games was headed
219
+ // "Disabled workflows".
220
+ disabledList ? group('Disabled workflows') : null,
221
+ ...(hasItems ? bullets(e.items, disabledList) : []),
222
+ e.workflowUrl ?? e.url ? '' : null,
223
+ fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
167
224
  ]);
168
225
  };
169
226
  const renderReport = (e) => {
170
227
  if (e.groups && e.groups.length > 0) {
171
228
  const body = e.groups.flatMap((g, i) => (i === 0 ? renderGroup(g) : ['', ...renderGroup(g)]));
172
- return join([typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title), '', ...body]);
229
+ return join([
230
+ typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title),
231
+ '',
232
+ ...body,
233
+ e.url ? '' : null,
234
+ fieldAction('Details', e.url, undefined)
235
+ ]);
173
236
  }
174
237
  const items = bullets(e.items, false);
175
238
  return join([
@@ -177,7 +240,11 @@ const renderReport = (e) => {
177
240
  '',
178
241
  ...(e.lines ?? []).map(([label, value]) => field(label, value)),
179
242
  items.length > 0 ? '' : null,
180
- ...items
243
+ ...items,
244
+ // Обе аналитики шлют сюда ссылку на снимок дня в docs/. Рендер её не читал,
245
+ // и дневной отчёт приходил без единственного способа посмотреть подробности.
246
+ e.url ? '' : null,
247
+ fieldAction('Details', e.url, undefined)
181
248
  ]);
182
249
  };
183
250
  const renderCi = (e) => {
@@ -186,10 +253,12 @@ const renderCi = (e) => {
186
253
  typeLine(icon, 'CI', e.status),
187
254
  '',
188
255
  fieldLink('Commit', e.commitUrl, e.commit),
189
- commitQuote(e.commitTitle, e.commitBody),
256
+ titleField(e.commitTitle),
257
+ bodyQuote(e.commitBody),
190
258
  field('Actor', e.actor),
191
- e.workflowUrl ? '' : null,
192
- fieldAction('Workflow', e.workflowUrl, e.workflowName)
259
+ field('Reason', e.note),
260
+ e.workflowUrl ?? e.url ? '' : null,
261
+ fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
193
262
  ]);
194
263
  };
195
264
  // PR/Issue: значок теперь по статусу (четыре на пакет), не по действию —
@@ -213,39 +282,67 @@ const ISSUE_ICON = {
213
282
  const renderPr = (e) => join([
214
283
  typeLine(PR_ICON[e.action], 'PR', e.action),
215
284
  '',
216
- fieldLink('Pr', e.url, `#${e.number}`),
217
- note(e.title),
218
- '',
285
+ // Идентификатор первым, заголовок под ним: так вещь читается «#118, вот
286
+ // такая», а не «вот такая, кстати #118» — и так её пишет сам GitHub.
287
+ fieldLink('Number', e.url, `#${e.number}`),
288
+ titleField(e.title),
289
+ bodyQuote(e.body),
290
+ // Без пустой строки перед автором: у задачи её нет, и одно и то же поле
291
+ // не должно стоять по-разному в двух соседних карточках. Пустая строка в
292
+ // этом формате означает «дальше указатель, куда пойти» — автор не он.
219
293
  field('Author', e.author),
220
294
  field('Reviewer', e.reviewer)
221
295
  ]);
222
296
  const renderIssue = (e) => join([
223
297
  typeLine(ISSUE_ICON[e.action], 'Issue', e.action),
224
298
  '',
225
- fieldLink('Issue', e.url, `#${e.number}`),
226
- commitQuote(e.title, e.body),
299
+ fieldLink('Number', e.url, `#${e.number}`),
300
+ titleField(e.title),
301
+ bodyQuote(e.body),
227
302
  field('Author', e.author),
228
303
  field('Assignee', e.assignee)
229
304
  ]);
230
305
  const renderIncident = (e) => join([
231
306
  typeLine(ICON.alarm, 'Incident', 'open'),
232
307
  '',
233
- field('Reason', e.detail ?? e.title),
308
+ // `detail` is a diagnosis of several lines (vault greps three of them plus a
309
+ // log path). It used to go through `field`, which keeps only the first line,
310
+ // so every alarm this package ever sent arrived gutted. Same shape as a
311
+ // commit now: short label, full text quoted under it.
312
+ // Ярлык `Title`, а не `Reason`: у аварии заголовок — такой же заголовок,
313
+ // как у коммита и задачи, и называться в одной карточке он должен так же.
314
+ titleField(e.title),
315
+ e.detail && e.detail !== e.title ? note(e.detail) : null,
234
316
  e.logs || e.url ? '' : null,
235
317
  fieldCode('Logs', e.logs),
236
318
  fieldAction('Workflow', e.url, undefined)
237
319
  ]);
320
+ // Раньше всё это склеивалось в одну строку `Reason:` через тире: «имя — no
321
+ // reports — expected X, last seen Y». Каждая другая карточка кладёт факт на
322
+ // свою строку с ярлыком, и владелец справедливо спросил, зачем тут отдельный
323
+ // формат. Отдельного формата больше нет.
238
324
  const renderHeartbeatMiss = (e) => {
239
325
  const icon = e.recovered ? ICON.ok : ICON.red;
240
326
  const action = e.recovered ? 'ok' : 'miss';
241
- const reason = e.note ??
242
- (e.recovered
243
- ? `${e.job} is reporting again${e.lastSeen ? ` — last run ${e.lastSeen}` : ''}`
244
- : `${e.job} — no reports${e.expected ? ` — expected ${e.expected}` : ''}${e.lastSeen ? `, last seen ${e.lastSeen}` : ''}`);
245
- return join([typeLine(icon, 'Heartbeat', action), '', field('Reason', reason)]);
327
+ return join([
328
+ typeLine(icon, 'Heartbeat', action),
329
+ '',
330
+ field('Task', e.job),
331
+ field('Reason', e.note),
332
+ field('Expected', e.expected),
333
+ field(e.recovered ? 'Last run' : 'Last seen', e.lastSeen)
334
+ ]);
246
335
  };
247
336
  // Подпись файла — та же карточка, но лимит Telegram у caption свой: 1024.
248
- const renderFile = (e) => join([typeLine(ICON.info, 'File', 'new'), '', field('Reason', e.note ?? e.title)]);
337
+ const renderFile = (e) => join([
338
+ typeLine(ICON.info, 'File', 'new'),
339
+ '',
340
+ // Раньше здесь стояло `field('Title', e.note ?? e.title)`: подпись файла
341
+ // приходила под ярлыком заголовка, а сам заголовок из карточки исчезал.
342
+ // Один ярлык — один смысл: Title это title, Reason это note.
343
+ field('Title', e.title),
344
+ field('Reason', e.note)
345
+ ]);
249
346
  const RENDERERS = {
250
347
  deploy: renderDeploy,
251
348
  job: renderJob,
@@ -327,7 +424,7 @@ export const render = (e) => {
327
424
  // строка, и неизвестное значение роняло процесс через `renderer is not a
328
425
  // function`. Падать из-за уведомления нельзя.
329
426
  if (typeof renderer !== 'function') {
330
- throw new Error(`неизвестный тип события: ${String(e.type)}`);
427
+ throw new Error(`unknown event type: ${String(e.type)}`);
331
428
  }
332
429
  const tags = tagsLine(e);
333
430
  // clampMessage может выйти за переданный limit на хвост закрывающих тегов и
package/dist/send.js CHANGED
@@ -89,7 +89,7 @@ const attempt = async (token, target, text) => {
89
89
  // почему уведомления пропали, невозможно — а разбираться будет не
90
90
  // разработчик, а владелец.
91
91
  const detail = (await res.json().catch(() => null));
92
- log(`HTTP ${res.status}: ${detail?.description ?? 'без описания'} — не повторяем, ошибка постоянная`);
92
+ log(`HTTP ${res.status}: ${detail?.description ?? 'no description'} — permanent error, not retried`);
93
93
  return { outcome: 'fail' };
94
94
  }
95
95
  catch (err) {
@@ -99,7 +99,7 @@ const attempt = async (token, target, text) => {
99
99
  // на таймауте останавливаемся и честно пишем 'failed': лишняя копия аварии
100
100
  // хуже, чем пропущенная строка в логе, а сообщение, скорее всего, ушло.
101
101
  if (err instanceof Error && err.name === 'TimeoutError') {
102
- log('таймаут ответане повторяем: сообщение могло уже уйти');
102
+ log('answer timed out not retried: the message may already be out');
103
103
  return { outcome: 'fail' };
104
104
  }
105
105
  // Сюда попадают отказы соединения (DNS, TLS, сеть недоступна) — запрос
@@ -109,7 +109,7 @@ const attempt = async (token, target, text) => {
109
109
  // отсутствие дублей невозможно без idempotency-key у Bot API (его нет).
110
110
  // Логика прежняя: дубль на редком reset — меньшее зло, чем потеря
111
111
  // сообщения на частом сетевом сбое.
112
- log('fetch не прошёл, пробуем curl…');
112
+ log('fetch did not go through, trying curl…');
113
113
  const curl = sendViaCurl(token, target, text);
114
114
  return curl === 'retry' ? { outcome: 'retry', waitMs: 1000 } : { outcome: curl };
115
115
  }
@@ -130,14 +130,14 @@ const sendOne = async (token, target, text) => {
130
130
  }
131
131
  waitMs = result.waitMs;
132
132
  }
133
- log('исчерпаны попытки отправки');
133
+ log('out of send attempts');
134
134
  return 'failed';
135
135
  };
136
136
  /** Общий хвост для `notify` и `sendReport`: токен, цели, последовательная отправка. */
137
137
  const deliver = async (where, text) => {
138
138
  const token = process.env.OPS_BOT_TOKEN?.trim();
139
139
  if (!token) {
140
- log('нет OPS_BOT_TOKEN сообщение не отправлено');
140
+ log('skipped: no OPS_BOT_TOKEN, the message was not sent');
141
141
  return 'skipped';
142
142
  }
143
143
  // Токен interpolируется в URL и в curl-конфиг (`url = "...bot${token}..."`).
@@ -145,7 +145,7 @@ const deliver = async (where, text) => {
145
145
  // строки/`?` сломало бы разбор (инъекция директивы curl или query-хвост).
146
146
  // Это требует покорёженного секрета, но проверка копеечная.
147
147
  if (!/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
148
- log('OPS_BOT_TOKEN не похож на токен Telegram отправка отменена');
148
+ log('failed: OPS_BOT_TOKEN does not look like a Telegram token, send cancelled');
149
149
  return 'skipped';
150
150
  }
151
151
  if (where.length === 0) {
@@ -196,27 +196,27 @@ const sendFileOnce = async (token, target, e, caption) => {
196
196
  return { outcome: 'retry', waitMs: 1000 };
197
197
  }
198
198
  const detail = (await res.json().catch(() => null));
199
- log(`HTTP ${res.status}: ${detail?.description ?? 'без описания'} — не повторяем, ошибка постоянная`);
199
+ log(`HTTP ${res.status}: ${detail?.description ?? 'no description'} — permanent error, not retried`);
200
200
  return { outcome: 'fail' };
201
201
  }
202
202
  catch (err) {
203
203
  if (err instanceof Error && err.name === 'TimeoutError') {
204
- log('таймаут ответане повторяем: файл мог уже уйти');
204
+ log('answer timed out not retried: the file may already be out');
205
205
  return { outcome: 'fail' };
206
206
  }
207
207
  // Файл не читается (нет на диске, нет прав) — постоянная ошибка.
208
208
  if (err instanceof Error && 'code' in err) {
209
- log(`файл не отправлен: ${err.message}`);
209
+ log(`failed to send the file: ${err.message}`);
210
210
  return { outcome: 'fail' };
211
211
  }
212
- log(`сеть не пустила файл: ${err instanceof Error ? err.message : String(err)}`);
212
+ log(`the network refused the file: ${err instanceof Error ? err.message : String(err)}`);
213
213
  return { outcome: 'retry', waitMs: 1000 };
214
214
  }
215
215
  };
216
216
  const sendFile = async (e) => {
217
217
  const token = process.env.OPS_BOT_TOKEN?.trim();
218
218
  if (!token || !/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
219
- log('нет валидного OPS_BOT_TOKEN файл не отправлен');
219
+ log('skipped: no valid OPS_BOT_TOKEN, the file was not sent');
220
220
  return 'skipped';
221
221
  }
222
222
  const where = targets(e);
@@ -261,13 +261,13 @@ const sendFile = async (e) => {
261
261
  const reportLostProject = async (project, kind) => {
262
262
  // Локальный лог называет и допустимые написания — это единственная
263
263
  // диагностика, доступная на машине, где случилась опечатка.
264
- log(`неизвестный проект «${String(project)}»известны: ${Object.keys(ROUTES).join(', ')}`);
264
+ log(`unknown project "${String(project)}"known: ${Object.keys(ROUTES).join(', ')}`);
265
265
  const lost = {
266
266
  type: 'job',
267
267
  project: 'mac-config',
268
- job: 'notify: событие потеряно',
268
+ job: 'notify: an event was lost',
269
269
  status: 'fail',
270
- note: `проект «${String(project)}» не в ROUTES — событие «${kind}» никуда не доставлено`,
270
+ note: `project "${String(project)}" is not in ROUTES — event "${kind}" went nowhere`,
271
271
  key: 'notify-unknown-project'
272
272
  };
273
273
  await deliver(targets(lost), render(lost)).catch(() => undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",