@mikitasazan/notify 1.2.1 → 1.4.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.
@@ -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,6 +21,8 @@
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}`);
@@ -39,11 +41,11 @@ if (command === 'setup') {
39
41
  const flags = new Map();
40
42
  const parseErrors = [];
41
43
  /** Флаги без значения. Всё остальное обязано его иметь. */
42
- const BOOLEAN_FLAGS = new Set(['json']);
44
+ const BOOLEAN_FLAGS = new Set(['json', 'recovered', 'dry-run']);
43
45
  for (let i = 1; i < args.length; i++) {
44
46
  const arg = args[i];
45
47
  if (!arg.startsWith('--')) {
46
- parseErrors.push(`лишний аргумент без флага: «${arg}»`);
48
+ parseErrors.push(`stray argument with no flag: "${arg}"`);
47
49
  continue;
48
50
  }
49
51
  // Форма `--key=value` обязательна для значений, начинающихся с `--`
@@ -65,13 +67,18 @@ for (let i = 1; i < args.length; i++) {
65
67
  // ТЕРЯЛОСЬ ВСЁ сообщение, а `--status` без значения рисовал 🔴 на
66
68
  // успешном деплое. Теперь это явная ошибка разбора.
67
69
  if (next === undefined || next.startsWith('--')) {
68
- parseErrors.push(`флаг --${key} без значения`);
70
+ parseErrors.push(`flag --${key} with no value`);
69
71
  continue;
70
72
  }
71
73
  i++;
72
74
  flags.set(key, [...(flags.get(key) ?? []), next]);
73
75
  }
74
76
  const one = (key) => flags.get(key)?.[0];
77
+ for (const key of flags.keys()) {
78
+ if (!KNOWN_FLAGS.has(key)) {
79
+ parseErrors.push(`unknown flag --${key}`);
80
+ }
81
+ }
75
82
  // Число с явной ошибкой разбора, иначе рендер рисовал «PR #NaN».
76
83
  const num = (key) => {
77
84
  const raw = one(key);
@@ -114,7 +121,7 @@ const ISSUE_ALIASES = {
114
121
  const prAction = (raw) => {
115
122
  const hit = PR_ALIASES[(raw ?? '').toLowerCase()];
116
123
  if (!hit) {
117
- parseErrors.push(`--action: неизвестное действие PR «${raw ?? ''}» (${Object.keys(PR_ALIASES).join(', ')})`);
124
+ parseErrors.push(`--action: unknown PR action "${raw ?? ''}" (${Object.keys(PR_ALIASES).join(', ')})`);
118
125
  return 'opened';
119
126
  }
120
127
  return hit;
@@ -122,7 +129,7 @@ const prAction = (raw) => {
122
129
  const issueAction = (raw) => {
123
130
  const hit = ISSUE_ALIASES[(raw ?? '').toLowerCase()];
124
131
  if (!hit) {
125
- parseErrors.push(`--action: неизвестное действие задачи «${raw ?? ''}» (${Object.keys(ISSUE_ALIASES).join(', ')})`);
132
+ parseErrors.push(`--action: unknown issue action "${raw ?? ''}" (${Object.keys(ISSUE_ALIASES).join(', ')})`);
126
133
  return 'opened';
127
134
  }
128
135
  return hit;
@@ -139,6 +146,15 @@ const status = () => {
139
146
  const raw = (one('status') ?? '').toLowerCase();
140
147
  return raw === 'ok' || raw === 'success' || raw === 'passed' || raw === '0' ? 'ok' : 'fail';
141
148
  };
149
+ // `job` — единственный тип с третьим состоянием (`disabled`): задача не
150
+ // провалилась сама, её выключил кто-то извне (GitHub Actions без минут).
151
+ const jobStatus = () => {
152
+ const raw = (one('status') ?? '').toLowerCase();
153
+ if (raw === 'disabled') {
154
+ return 'disabled';
155
+ }
156
+ return raw === 'ok' || raw === 'success' || raw === 'passed' || raw === '0' ? 'ok' : 'fail';
157
+ };
142
158
  let event;
143
159
  if (flags.has('json')) {
144
160
  try {
@@ -148,7 +164,9 @@ if (flags.has('json')) {
148
164
  event = { ...payload, type: command };
149
165
  }
150
166
  catch (err) {
151
- log(`не удалось разобрать --json со stdin: ${err instanceof Error ? err.message : String(err)}`);
167
+ // Тоже в parseErrors: обе аналитики зовут CLI через `|| true`, и молчащий
168
+ // разбор JSON означал бы зелёный крон без дневного отчёта.
169
+ parseErrors.push(`--json from stdin did not parse: ${err instanceof Error ? err.message : String(err)}`);
152
170
  }
153
171
  }
154
172
  else {
@@ -160,6 +178,10 @@ else {
160
178
  status: status(),
161
179
  commit: one('commit'),
162
180
  commitUrl: one('commit-url'),
181
+ commitTitle: one('commit-title'),
182
+ commitBody: one('commit-body'),
183
+ workflowUrl: one('workflow-url'),
184
+ workflowName: one('workflow-name'),
163
185
  url: one('url'),
164
186
  target: one('target'),
165
187
  via: one('via'),
@@ -171,10 +193,12 @@ else {
171
193
  type: 'job',
172
194
  project: project(),
173
195
  job: one('job') ?? '(без имени)',
174
- status: status(),
196
+ status: jobStatus(),
175
197
  stats: pairs('stat'),
176
198
  items: items(),
177
199
  note: one('note'),
200
+ workflowUrl: one('workflow-url'),
201
+ workflowName: one('workflow-name'),
178
202
  url: one('url')
179
203
  };
180
204
  break;
@@ -196,7 +220,13 @@ else {
196
220
  status: status(),
197
221
  branch: one('branch'),
198
222
  commit: one('commit'),
223
+ commitUrl: one('commit-url'),
224
+ commitTitle: one('commit-title'),
225
+ commitBody: one('commit-body'),
199
226
  actor: one('actor'),
227
+ note: one('note'),
228
+ workflowUrl: one('workflow-url'),
229
+ workflowName: one('workflow-name'),
200
230
  url: one('url')
201
231
  };
202
232
  break;
@@ -207,6 +237,7 @@ else {
207
237
  action: prAction(one('action')),
208
238
  number: num('number'),
209
239
  title: one('title') ?? '(без заголовка)',
240
+ body: one('body'),
210
241
  author: one('author'),
211
242
  reviewer: one('reviewer'),
212
243
  url: one('url')
@@ -219,6 +250,7 @@ else {
219
250
  action: issueAction(one('action')),
220
251
  number: num('number'),
221
252
  title: one('title') ?? '(без заголовка)',
253
+ body: one('body'),
222
254
  author: one('author'),
223
255
  assignee: one('assignee'),
224
256
  url: one('url')
@@ -230,6 +262,7 @@ else {
230
262
  project: project(),
231
263
  title: one('title') ?? '(без заголовка)',
232
264
  detail: one('detail'),
265
+ logs: one('logs'),
233
266
  url: one('url')
234
267
  };
235
268
  break;
@@ -239,7 +272,9 @@ else {
239
272
  project: project(),
240
273
  job: one('job') ?? '(без имени)',
241
274
  lastSeen: one('last-seen'),
242
- expected: one('expected')
275
+ expected: one('expected'),
276
+ recovered: flags.has('recovered'),
277
+ note: one('note')
243
278
  };
244
279
  break;
245
280
  case 'file': {
@@ -258,7 +293,10 @@ else {
258
293
  break;
259
294
  }
260
295
  default:
261
- log(`неизвестный тип события: ${command ?? '(не указан)'}`);
296
+ // В parseErrors, а не просто в лог: иначе неизвестный тип уходил в
297
+ // тишину — событие не собиралось, ошибок разбора не было, и CLI выходил
298
+ // нулём, ничего не отправив и ничего об этом не сказав.
299
+ parseErrors.push(`unknown event type: ${command ?? '(none given)'}`);
262
300
  }
263
301
  }
264
302
  // --key применим к любому типу — одна точка вместо строки в каждом case
@@ -273,7 +311,26 @@ if (parseErrors.length > 0) {
273
311
  for (const err of parseErrors) {
274
312
  log(err);
275
313
  }
276
- log('событие не отправлено исправь команду');
314
+ // The word `failed` is a CONTRACT, not prose. Two readers match on it: the
315
+ // GitHub Action turns it into a yellow annotation, and the VPS watchdog reads
316
+ // the combined stream for `sent|failed|skipped`. Before this, a parse error
317
+ // printed neither word — the run stayed green, the watchdog stayed quiet, and
318
+ // the card simply never existed.
319
+ // It must NOT contain the substring `sent`: heartbeat-check.sh matches `*sent*`
320
+ // and would read a failure as a success.
321
+ log('failed: bad command, nothing delivered');
322
+ process.exit(0);
323
+ }
324
+ if (event && flags.has('dry-run')) {
325
+ // The rendered card on STDOUT, nothing sent and no token needed. This is how
326
+ // a change to the format is shown to the owner before it reaches a forum, and
327
+ // how ~25 edited call sites are checked one by one — the package always exits
328
+ // 0, so a typo in a flag is otherwise silent.
329
+ //
330
+ // stdout, not stderr: every other line this CLI prints goes to stderr, and
331
+ // watchdogs read that stream for the words `sent|failed|skipped`. A card
332
+ // printed there would be read as a verdict.
333
+ process.stdout.write(`${render(event)}\n`);
277
334
  process.exit(0);
278
335
  }
279
336
  if (event) {
@@ -284,7 +341,9 @@ if (event) {
284
341
  log(await notify(event));
285
342
  }
286
343
  catch (err) {
287
- log(`не отправлено: ${err instanceof Error ? err.message : String(err)}`);
344
+ // Слово `failed` контракт, тот же, что у ошибки разбора выше. Без него
345
+ // исключение при отправке читалось сторожами как «ничего не случилось».
346
+ log(`failed: ${err instanceof Error ? err.message : String(err)}`);
288
347
  }
289
348
  }
290
349
  process.exit(0);
package/dist/events.d.ts CHANGED
@@ -29,9 +29,16 @@ type Keyed = {
29
29
  * Позиция списка внутри сообщения: задача из дайджеста, упавшая проверка,
30
30
  * замечание. `url` необязателен — тогда рендерится просто строкой.
31
31
  */
32
+ /**
33
+ * `label` — необязательный жирный префикс перед `text` (`#243 (overdue)`,
34
+ * `#287`) для позиций внутри именованных групп отчёта. Без `label` позиция
35
+ * рендерится как обычная нумерованная/маркированная строка — так уже
36
+ * работают дайджест-задачи и список выключенных workflow.
37
+ */
32
38
  export type Item = {
33
39
  text: string;
34
40
  url?: string;
41
+ label?: string;
35
42
  };
36
43
  export type NotifyEvent = Keyed & (
37
44
  /** Выкатка кода на сервер. */
@@ -42,6 +49,13 @@ export type NotifyEvent = Keyed & (
42
49
  commit?: string;
43
50
  /** Ссылка на коммит — строка «коммит» становится кликабельной. */
44
51
  commitUrl?: string;
52
+ /** Заголовок коммита — рендерится рядом с телом в цитате. */
53
+ commitTitle?: string;
54
+ /** Тело коммита, если есть — та же цитата, что и заголовок. */
55
+ commitBody?: string;
56
+ workflowUrl?: string;
57
+ /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
58
+ workflowName?: string;
45
59
  url?: string;
46
60
  /**
47
61
  * Куда выкатили. Заполнять ТОЛЬКО когда окружений больше одного: у сайтов
@@ -64,11 +78,20 @@ export type NotifyEvent = Keyed & (
64
78
  type: 'job';
65
79
  project: Project;
66
80
  job: string;
67
- status: 'ok' | 'fail';
81
+ /** `disabled` задача выключена извне (например GitHub Actions кончил бесплатные минуты), не провалилась сама. */
82
+ status: 'ok' | 'fail' | 'disabled';
68
83
  stats?: Array<[label: string, value: string | number]>;
69
- /** Детали: что именно упало, замечания прогона. */
84
+ /** Детали: что именно упало, замечания прогона; у `disabled` — список выключенных процессов (каждый со своей ссылкой). */
70
85
  items?: Item[];
71
86
  note?: string;
87
+ workflowUrl?: string;
88
+ /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
89
+ workflowName?: string;
90
+ /**
91
+ * Запасное имя для ссылки на прогон: половина отправителей шлёт её как
92
+ * `--url`. Рендер берёт `workflowUrl ?? url`, так что оба имени работают.
93
+ * В новых вызовах предпочитай `workflowUrl` — оно говорит, куда ведёт.
94
+ */
72
95
  url?: string;
73
96
  }
74
97
  /** Сводка с цифрами: дневной отчёт, дайджест аналитики. */
@@ -77,13 +100,24 @@ export type NotifyEvent = Keyed & (
77
100
  project: Project;
78
101
  title: string;
79
102
  period?: string;
80
- lines: Array<[label: string, value: string | number]>;
103
+ /** Пусто/не передано, когда используются `groups` — два вида отчёта не смешиваются в одном событии. */
104
+ lines?: Array<[label: string, value: string | number]>;
81
105
  /**
82
106
  * Список позиций со ссылками — для дайджестов задач, где ценность в
83
107
  * самих названиях, а не в цифре. Рендерятся отдельным блоком после
84
108
  * `lines`.
85
109
  */
86
110
  items?: Item[];
111
+ /**
112
+ * Именованные группы (доска задач: Ready/In Progress/Not on the
113
+ * board; аналитика: Metrics/Links) — каждая со своим заголовком и
114
+ * списком позиций. Заменяет `lines`/`items`, когда задан: разные
115
+ * отчёты используют либо плоский вид, либо группы, не оба разом.
116
+ */
117
+ groups?: Array<{
118
+ name: string;
119
+ items: Item[];
120
+ }>;
87
121
  url?: string;
88
122
  }
89
123
  /** Итог CI на основной ветке. */
@@ -93,7 +127,22 @@ export type NotifyEvent = Keyed & (
93
127
  status: 'ok' | 'fail';
94
128
  branch?: string;
95
129
  commit?: string;
130
+ /** Ссылка на коммит — хэш становится кликабельным. */
131
+ commitUrl?: string;
132
+ /** Заголовок коммита (subject) — отдельное поле `Title:`, не цитата. */
133
+ commitTitle?: string;
134
+ /** Тело коммита (после subject) — та же цитата, что и заголовок. */
135
+ commitBody?: string;
96
136
  actor?: string;
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`, который у CI не используется. */
143
+ workflowUrl?: string;
144
+ /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
145
+ workflowName?: string;
97
146
  url?: string;
98
147
  }
99
148
  /**
@@ -107,6 +156,8 @@ export type NotifyEvent = Keyed & (
107
156
  action: 'opened' | 'ready_for_review' | 'review_requested' | 'approved' | 'changes_requested' | 'merged' | 'closed';
108
157
  number: number;
109
158
  title: string;
159
+ /** PR description — quoted on its own; the title is the `Title:` field above it. */
160
+ body?: string;
110
161
  author?: string;
111
162
  reviewer?: string;
112
163
  url?: string;
@@ -118,6 +169,8 @@ export type NotifyEvent = Keyed & (
118
169
  action: 'opened' | 'assigned' | 'closed';
119
170
  number: number;
120
171
  title: string;
172
+ /** Тело задачи — цитата под полем `Title:`, отдельно от заголовка. */
173
+ body?: string;
121
174
  author?: string;
122
175
  assignee?: string;
123
176
  url?: string;
@@ -128,6 +181,8 @@ export type NotifyEvent = Keyed & (
128
181
  project: Project;
129
182
  title: string;
130
183
  detail?: string;
184
+ /** Локальный путь к логам (не URL — рендерится моноширинным, для копирования, не для клика). */
185
+ logs?: string;
131
186
  url?: string;
132
187
  }
133
188
  /** Задача не отметилась вовремя — сторож молчания (heartbeat). */
@@ -137,6 +192,10 @@ export type NotifyEvent = Keyed & (
137
192
  job: string;
138
193
  lastSeen?: string;
139
194
  expected?: string;
195
+ /** Задача снова отчиталась — тот же тип, зелёная карточка вместо красной, ключ (для сверки) не меняется. */
196
+ recovered?: boolean;
197
+ /** Готовое предложение-причина; без него собирается из lastSeen/expected. */
198
+ note?: string;
140
199
  }
141
200
  /**
142
201
  * Файл-вложение (sendDocument) с подписью-карточкой. Появился, когда
package/dist/events.js CHANGED
@@ -11,10 +11,17 @@
11
11
  */
12
12
  /** Красное = со звуком. Всё остальное — тихо. (Отдельной темы «инциденты» больше нет — авария видна в ленте проекта.) */
13
13
  export const severity = (e) => {
14
- if (e.type === 'incident' || e.type === 'heartbeat_miss') {
14
+ if (e.type === 'heartbeat_miss') {
15
+ return e.recovered ? 'info' : 'error';
16
+ }
17
+ if (e.type === 'incident') {
15
18
  return 'error';
16
19
  }
17
- if ('status' in e && e.status === 'fail') {
20
+ // `disabled` рисуется красным (`ICON.red` в render.ts) ровно как `fail`
21
+ // задача не работает, что бы ни было тому причиной. Молчаливая отправка
22
+ // красной карточки без звука хуже отсутствия карточки: авария выглядит
23
+ // аварией, но не будит (тот же довод, что уже был у `fail`).
24
+ if ('status' in e && (e.status === 'fail' || e.status === 'disabled')) {
18
25
  return 'error';
19
26
  }
20
27
  return 'info';
package/dist/render.d.ts CHANGED
@@ -1,14 +1,23 @@
1
1
  /**
2
- * Один рендерер на тип события, все по одному каркасу:
2
+ * Один рендерер на тип события, все по одному каркасу — утверждён
3
+ * владельцем 20.08.2026 после ~15 живых раундов в тестовом форуме:
3
4
  *
4
- * эмодзи Заголовок · проект
5
- * ключ: значение
6
- * ключ: значение
7
- * <a href="…">Ссылка</a>
5
+ * #тип #экземпляр
6
+ * значок <b>Тип:</b> действие
8
7
  *
9
- * Проект указывается ВСЕГДА, даже в теме самого проекта — в теме
10
- * `🔴 incidents` сообщения четырёх проектов лежат вперемешку, и формат
11
- * должен быть один и тот же независимо от того, куда сообщение попало.
8
+ * <b>Ярлык:</b> значение
9
+ * <blockquote>цитата чужого текста тело коммита, тело задачи</blockquote>
10
+ *
11
+ * <i><u>Группа</u></i>
12
+ * <b>#N (overdue):</b> <a>заголовок</a>
13
+ *
14
+ * <b>Ярлык:</b> значение ← действия/направления
15
+ *
16
+ * Три уровня начертания, никогда не смешиваются: поле — жирный ярлык с
17
+ * большой буквы + обычное значение; группа — курсив+подчёркивание, без
18
+ * жирности и без двоеточия; строка 2 (тип) — тот же закон поля. Пустая
19
+ * строка разделяет БЛОКИ ПО СМЫСЛУ (шапка / суть / действия), не механически
20
+ * после каждой строки.
12
21
  */
13
22
  import type { NotifyEvent } from './events.ts';
14
23
  /** Экранируется ВСЁ, что пришло снаружи — теги ставит только шаблон. */
@@ -27,11 +36,20 @@ export declare const esc: (v: unknown) => string;
27
36
  * повторяем — сообщение исчезало совсем.
28
37
  */
29
38
  export declare const clampMessage: (text: string, limit?: number) => string;
39
+ /**
40
+ * Экземпляр-тег: что именно это конкретное событие (ветка, окружение,
41
+ * задача, номер) — по нему разборщик сверяет 🔴 с более поздней зелёной
42
+ * карточкой ТОГО ЖЕ экземпляра. Явный `key` побеждает всегда; без него —
43
+ * выводится из самых стабильных полей типа (ветка/окружение важнее заголовка,
44
+ * потому что заголовок у регулярной задачи не меняется, а у отчёта как раз
45
+ * заголовок и есть единственное стабильное поле).
46
+ */
30
47
  export declare const eventKey: (e: NotifyEvent) => string;
31
48
  /**
32
49
  * Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
33
- * Ключ добавляется ПОСЛЕ обрезки, с зарезервированным местом: обрезанная
34
- * карточка без ключа была бы невидима разборщикуровно на самых длинных,
35
- * то есть самых важных сообщениях.
50
+ * Теги ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
51
+ * несут и человеческий фильтр, и машинный ключ разборщика обрезанная
52
+ * карточка без них была бы не только некликабельной, но и невидимой
53
+ * разборщику ровно на самых длинных, то есть самых важных сообщениях.
36
54
  */
37
55
  export declare const render: (e: NotifyEvent) => string;
package/dist/render.js CHANGED
@@ -1,3 +1,5 @@
1
+ /** Первая буква — заглавная, остальное как есть (ga4/GitHub остаются собой). */
2
+ const cap = (s) => (s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s);
1
3
  /** Экранируется ВСЁ, что пришло снаружи — теги ставит только шаблон. */
2
4
  export const esc = (v) => String(v ?? '')
3
5
  .replace(/&/g, '&amp;')
@@ -39,7 +41,12 @@ export const clampMessage = (text, limit = 4000) => {
39
41
  // blockquote — с приходом цитаты для примечаний/деталей длинный detail режется
40
42
  // прямо посередине неё, и без этого тега Telegram отвечал бы 400 на незакрытую
41
43
  // цитату (regex `<blockquote[ >]` ловит и вариант с атрибутом `expandable`).
42
- const tail = ['b', 'a', 'i', 'code', 'blockquote']
44
+ // `u` в списке с 2026-08-25: заголовок группы рисуется как `<i><u>…</u></i>`,
45
+ // и обрезанный посередине длинный заголовок оставлял `<u>` незакрытым.
46
+ // Telegram отвечает на такое 400 — то есть карточка пропадала целиком, а
47
+ // отправитель с `|| true` этого не замечал. Нашёл Codex; воспроизводится
48
+ // отчётом с именем группы в 5000 знаков.
49
+ const tail = ['b', 'a', 'i', 'u', 'code', 'blockquote']
43
50
  .filter((t) => {
44
51
  const opened = (body.match(new RegExp(`<${t}[ >]`, 'g')) ?? []).length;
45
52
  const closed = (body.match(new RegExp(`</${t}>`, 'g')) ?? []).length;
@@ -49,12 +56,6 @@ export const clampMessage = (text, limit = 4000) => {
49
56
  .join('');
50
57
  return `${body}${tail}\n…`;
51
58
  };
52
- const header = (icon, title, project) => `${icon} <b>${esc(title)}</b> · ${esc(project)}`;
53
- // Тире, не жирное значение: сплошной жирный текст в первой версии карточки
54
- // читался как крик (жалоба владельца 18.08). Иконка-лид уже держит внимание
55
- // на заголовке, факты идут построчно и без выделения — глаз сам находит
56
- // цифру рядом с меткой.
57
- //
58
59
  // Только первая строка: однострочное поле по контракту (коммит, ветка,
59
60
  // автор, статистика), а не место для абзаца. Живой случай (18.08): CI-карточка
60
61
  // понесла ПОЛНОЕ тело коммита с историей под-коммитов через `--commit` и вместо
@@ -67,7 +68,51 @@ const firstLine = (value) => {
67
68
  }
68
69
  return `${value.split('\n')[0]}…`;
69
70
  };
70
- const kv = (label, value) => value === undefined || value === '' ? null : `${esc(label)} — ${esc(firstLine(value))}`;
71
+ /**
72
+ * Поле: `<b>Ярлык:</b> значение` — жирный ярлык с большой буквы, значение
73
+ * обычным. `null` отбрасывается наравне с `undefined`/`''` — источники поля
74
+ * это JSON со stdin (`--json`) и объекты с сервера, где отсутствующее
75
+ * значение сериализуется как `null`, а не как пропущенный ключ.
76
+ */
77
+ const field = (label, value) => value === undefined || value === null || value === '' ? null : `<b>${esc(cap(label))}:</b> ${esc(firstLine(value))}`;
78
+ /**
79
+ * Поле-идентификатор (`commit:`/`pr:`/`issue:`): значение — ссылка, если
80
+ * она есть, иначе обычный текст того же поля — идентификатор не должен
81
+ * пропадать целиком только потому, что вызывающий не передал url.
82
+ */
83
+ const fieldLink = (label, url, text) => {
84
+ if (text === undefined || text === null || text === '') {
85
+ return null;
86
+ }
87
+ // `firstLine` here for the same reason `field` has it: the linked case used to
88
+ // skip it, so a multi-line value (arvent's two-line `commit`) became two-line
89
+ // LINK TEXT instead of one identifier.
90
+ return url
91
+ ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(firstLine(text))}</a>`
92
+ : field(label, text);
93
+ };
94
+ /**
95
+ * Поле-действие (`workflow:`): в отличие от `fieldLink`, без URL это НЕ
96
+ * поле — прогону просто некуда вести, показывать голое слово «run» без
97
+ * ссылки бессмысленнее, чем не показывать строку вовсе.
98
+ */
99
+ // Текст ссылки — имя того, куда она ведёт (имя workflow, имя прогона). Запасное
100
+ // слово было «run»: существительное, которое ничего не называет — владелец читал
101
+ // «Workflow: run» и не понимал, что это. «open» — глагол, он хотя бы честно
102
+ // говорит, что это ссылка, а не название.
103
+ const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'open')}</a>` : null;
104
+ /** Моноширинное поле — путь/команда для копирования, не ссылка. */
105
+ const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
106
+ /** Заголовок группы: курсив + подчёркивание, без жирности, без двоеточия. */
107
+ const group = (name) => `<i><u>${esc(cap(name))}</u></i>`;
108
+ /** Позиция внутри группы: `<b>label:</b> <a>text</a>` — либо простая маркированная/нумерованная строка без label. */
109
+ const groupItem = (it, index, numbered) => {
110
+ const linked = it.url ? `<a href="${esc(it.url)}">${esc(it.text)}</a>` : esc(it.text);
111
+ if (it.label) {
112
+ return `<b>${esc(it.label)}:</b> ${linked}`;
113
+ }
114
+ return numbered ? `${index + 1}. ${linked}` : `• ${linked}`;
115
+ };
71
116
  // Длинное пояснение (примечание, детали инцидента) — цитатой: у Telegram это
72
117
  // полоска слева и лёгкий отступ, читается как «подробности», а не как часть
73
118
  // заголовка. Длиннее ~400 знаков — цитата сворачивается сама (`expandable`,
@@ -80,109 +125,209 @@ const note = (text) => {
80
125
  const body = esc(text);
81
126
  return body.length > EXPAND_AT ? `<blockquote expandable>${body}</blockquote>` : `<blockquote>${body}</blockquote>`;
82
127
  };
83
- const link = (url, label) => url ? `<a href="${esc(url)}">${esc(label)}</a>` : null;
84
128
  const join = (parts) => parts.filter((p) => p !== null).join('\n');
85
- /** Список позиций общий для `job` и `report`, чтобы они не разъехались. */
86
- const bullets = (items) => (items ?? []).map((it) => (it.url ? `• <a href="${esc(it.url)}">${esc(it.text)}</a>` : `• ${esc(it.text)}`));
129
+ /** Плоский список позиций (без ярлыков) job/report без групп. */
130
+ const bullets = (items, numbered) => (items ?? []).map((it, i) => groupItem(it, i, numbered));
131
+ /** Именованная группа целиком: заголовок + позиции, разделены строкой пустоты внутри вызова через join. */
132
+ const renderGroup = (g) => [
133
+ group(g.name),
134
+ ...g.items.map((it, i) => groupItem(it, i, false))
135
+ ];
136
+ /**
137
+ * ОДНО правило на все карточки, где есть и заголовок, и тело: заголовок —
138
+ * обычное поле `Title:`, тело — цитата, и в цитате больше ничего нет.
139
+ *
140
+ * Раньше заголовок клался В ЦИТАТУ вместе с телом, разделённые пустой
141
+ * строкой. Владелец нашёл, чем это плохо: заголовок — главное в карточке, то,
142
+ * ЧТО это, а лежал он серым текстом того же веса, что и описание, и отличить
143
+ * одно от другого можно было только по пустой строке. У PR без тела карточка
144
+ * вырождалась в одинокую серую цитату из одной строки.
145
+ *
146
+ * Заголовок режется до первой строки: многострочный subject коммита не должен
147
+ * затягивать в поле собственное тело.
148
+ */
149
+ const titleField = (title) => field('Title', title);
150
+ const bodyQuote = (body) => body ? note(body) : null;
151
+ // Значок = статус сообщения, не тип события. Ровно четыре на весь пакет —
152
+ // закреплённая легенда в форумах обещает это владельцу как факт, не как
153
+ // приближение. 🔴 сломалось, 🚨 инцидент, ✅ прошло, ℹ️ к сведению.
154
+ const ICON = { red: '🔴', alarm: '🚨', ok: '✅', info: 'ℹ️' };
155
+ /** Строка 2: значок вне жирного, `<b>Тип:</b> действие` — то же поле, не особый случай. */
156
+ // `action` объявлен строкой, но приходит и из `--json`, и из прямых вызовов на
157
+ // JS, где типов нет. Пустое или отсутствующее значение давало строку `ℹ️ null`
158
+ // прямо во второй строке карточки. Пустая строка честнее: поле просто исчезает.
159
+ const typeLine = (icon, type, action) => {
160
+ // `field` возвращает null на пустом значении, а интерполяция null в шаблон
161
+ // печатает слово «null». Так вторая строка карточки становилась `ℹ️ null` —
162
+ // достижимо через `--json` и прямой вызов на JS, где типов нет.
163
+ const line = field(type, action);
164
+ return line === null ? `${icon} <b>${esc(cap(type))}</b>` : `${icon} ${line}`;
165
+ };
166
+ // `workflowUrl ?? url`: половина отправителей шлёт ссылку на прогон под именем
167
+ // `--url` — это имя было в пакете раньше и осталось в вызовах. Рендер читал
168
+ // только `workflowUrl`, поэтому красная карточка приходила БЕЗ ЕДИНОЙ ССЫЛКИ
169
+ // на логи. Отвергать `--url` было бы честнее по имени и хуже по делу: намерение
170
+ // однозначно, а карточка без ссылки бесполезна ровно тогда, когда нужна.
87
171
  const renderDeploy = (e) => {
88
- const icon = e.status === 'ok' ? '✅' : '🔴';
89
- const title = e.status === 'ok' ? 'Деплой завершён' : 'Деплой упал';
90
- // Коммит со ссылкой — кликабельная строка вместо голого текста; жалоба
91
- // владельца на некликабельные дайджесты распространяется и сюда.
92
- const commitLine = e.commit
93
- ? e.commitUrl
94
- ? `коммит: <a href="${esc(e.commitUrl)}"><b>${esc(firstLine(e.commit))}</b></a>`
95
- : kv('коммит', e.commit)
96
- : null;
172
+ const icon = e.status === 'ok' ? ICON.ok : ICON.red;
97
173
  return join([
98
- header(icon, title, e.project),
99
- commitLine,
100
- kv('откуда', e.via),
101
- kv('куда', e.target),
102
- note(e.note),
103
- link(e.url, 'Открыть логи')
174
+ typeLine(icon, 'Deploy', e.status),
175
+ '',
176
+ fieldLink('Commit', e.commitUrl, e.commit),
177
+ titleField(e.commitTitle),
178
+ bodyQuote(e.commitBody),
179
+ field('Via', e.via),
180
+ field('Target', e.target),
181
+ field('Reason', e.note),
182
+ e.workflowUrl ?? e.url ? '' : null,
183
+ fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
104
184
  ]);
105
185
  };
106
186
  const renderJob = (e) => {
107
- const icon = e.status === 'ok' ? '' : '🔴';
108
- const items = bullets(e.items);
187
+ const icon = e.status === 'fail' || e.status === 'disabled' ? ICON.red : ICON.ok;
188
+ const hasItems = (e.items ?? []).length > 0;
189
+ const disabledList = hasItems && e.status === 'disabled';
109
190
  return join([
110
- header(icon, e.job, e.project),
111
- ...(e.stats ?? []).map(([label, value]) => kv(label, value)),
112
- items.length > 0 ? '' : null,
113
- ...items,
114
- note(e.note),
115
- link(e.url, 'Подробнее')
191
+ typeLine(icon, 'Job', e.status),
192
+ '',
193
+ // The name is NOT the type line: line 2 is `Job: fail` by the format's own
194
+ // rule, so the name is its own field. Called `Task:` and not `Job:` because
195
+ // repeating the label of the line right above it reads as a mistake.
196
+ // Until now the name was dropped entirely — every caller passed it and the
197
+ // owner only ever saw it as the small grey instance tag.
198
+ field('Task', e.job),
199
+ field('Reason', e.note),
200
+ ...(e.stats ?? []).map(([label, value]) => field(label, value)),
201
+ hasItems ? '' : null,
202
+ // Heading ONLY for `disabled`. It used to print for any job carrying a
203
+ // list, so playhub's daily card of newly published games was headed
204
+ // "Disabled workflows".
205
+ disabledList ? group('Disabled workflows') : null,
206
+ ...(hasItems ? bullets(e.items, disabledList) : []),
207
+ e.workflowUrl ?? e.url ? '' : null,
208
+ fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
116
209
  ]);
117
210
  };
118
211
  const renderReport = (e) => {
119
- const items = bullets(e.items);
212
+ if (e.groups && e.groups.length > 0) {
213
+ const body = e.groups.flatMap((g, i) => (i === 0 ? renderGroup(g) : ['', ...renderGroup(g)]));
214
+ return join([
215
+ typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title),
216
+ '',
217
+ ...body,
218
+ e.url ? '' : null,
219
+ fieldAction('Details', e.url, undefined)
220
+ ]);
221
+ }
222
+ const items = bullets(e.items, false);
120
223
  return join([
121
- header('📊', e.title, e.project),
122
- e.period ? esc(e.period) : null,
123
- e.period ? '' : null,
124
- ...e.lines.map(([label, value]) => kv(label, value)),
224
+ typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title),
225
+ '',
226
+ ...(e.lines ?? []).map(([label, value]) => field(label, value)),
125
227
  items.length > 0 ? '' : null,
126
228
  ...items,
127
- link(e.url, 'Открыть отчёт')
229
+ // Обе аналитики шлют сюда ссылку на снимок дня в docs/. Рендер её не читал,
230
+ // и дневной отчёт приходил без единственного способа посмотреть подробности.
231
+ e.url ? '' : null,
232
+ fieldAction('Details', e.url, undefined)
128
233
  ]);
129
234
  };
130
235
  const renderCi = (e) => {
131
- const icon = e.status === 'ok' ? '✅' : '🔴';
132
- const title = e.status === 'ok' ? 'CI зелёный' : 'CI упал';
236
+ const icon = e.status === 'ok' ? ICON.ok : ICON.red;
133
237
  return join([
134
- header(icon, title, e.project),
135
- kv('ветка', e.branch),
136
- kv('коммит', e.commit),
137
- kv('автор', e.actor),
138
- link(e.url, 'Открыть логи')
238
+ typeLine(icon, 'CI', e.status),
239
+ '',
240
+ fieldLink('Commit', e.commitUrl, e.commit),
241
+ titleField(e.commitTitle),
242
+ bodyQuote(e.commitBody),
243
+ field('Actor', e.actor),
244
+ field('Reason', e.note),
245
+ e.workflowUrl ?? e.url ? '' : null,
246
+ fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
139
247
  ]);
140
248
  };
141
- // Значок у каждого вида свой: в ленте Ops событие узнаётся по нему до чтения
142
- // текста. Дублировать значок между видами нельзя — легенда закреплена в теме
143
- // и обещает однозначность.
144
- const PR_TITLES = {
145
- opened: { icon: '🔀', verb: 'открыт' },
146
- ready_for_review: { icon: '📤', verb: 'готов к ревью' },
147
- review_requested: { icon: '👁', verb: 'ждёт ревью' },
148
- approved: { icon: '👍', verb: 'ревью пройдено' },
149
- changes_requested: { icon: '📝', verb: 'запрошены правки' },
150
- merged: { icon: '✅', verb: 'смёржен' },
151
- closed: { icon: '⛔', verb: 'закрыт без слияния' }
152
- };
153
- const ISSUE_TITLES = {
154
- opened: { icon: '🆕', verb: 'заведена' },
155
- assigned: { icon: '🙋', verb: 'взята в работу' },
156
- closed: { icon: '☑️', verb: 'закрыта' }
249
+ // PR/Issue: значок теперь по статусу (четыре на пакет), не по действию
250
+ // `merged`/`approved` = успех, `changes_requested` = требует внимания,
251
+ // остальное = к сведению. Слово действия само по себе уже говорит, что
252
+ // произошло (`opened`, `ready_for_review` и т.д.), значок дублировать не должен.
253
+ const PR_ICON = {
254
+ opened: ICON.info,
255
+ ready_for_review: ICON.info,
256
+ review_requested: ICON.info,
257
+ approved: ICON.ok,
258
+ changes_requested: ICON.red,
259
+ merged: ICON.ok,
260
+ closed: ICON.info
157
261
  };
158
- const renderPr = (e) => {
159
- const { icon, verb } = PR_TITLES[e.action];
160
- return join([
161
- header(icon, `PR #${e.number} ${verb}`, e.project),
162
- esc(e.title),
163
- kv('автор', e.author),
164
- kv('ревьюер', e.reviewer),
165
- link(e.url, 'Открыть PR')
166
- ]);
262
+ const ISSUE_ICON = {
263
+ opened: ICON.info,
264
+ assigned: ICON.info,
265
+ closed: ICON.ok
167
266
  };
168
- const renderIssue = (e) => {
169
- const { icon, verb } = ISSUE_TITLES[e.action];
267
+ const renderPr = (e) => join([
268
+ typeLine(PR_ICON[e.action], 'PR', e.action),
269
+ '',
270
+ // Идентификатор первым, заголовок под ним: так вещь читается «#118, вот
271
+ // такая», а не «вот такая, кстати #118» — и так её пишет сам GitHub.
272
+ fieldLink('Number', e.url, `#${e.number}`),
273
+ titleField(e.title),
274
+ bodyQuote(e.body),
275
+ // Без пустой строки перед автором: у задачи её нет, и одно и то же поле
276
+ // не должно стоять по-разному в двух соседних карточках. Пустая строка в
277
+ // этом формате означает «дальше указатель, куда пойти» — автор не он.
278
+ field('Author', e.author),
279
+ field('Reviewer', e.reviewer)
280
+ ]);
281
+ const renderIssue = (e) => join([
282
+ typeLine(ISSUE_ICON[e.action], 'Issue', e.action),
283
+ '',
284
+ fieldLink('Number', e.url, `#${e.number}`),
285
+ titleField(e.title),
286
+ bodyQuote(e.body),
287
+ field('Author', e.author),
288
+ field('Assignee', e.assignee)
289
+ ]);
290
+ const renderIncident = (e) => join([
291
+ typeLine(ICON.alarm, 'Incident', 'open'),
292
+ '',
293
+ // `detail` is a diagnosis of several lines (vault greps three of them plus a
294
+ // log path). It used to go through `field`, which keeps only the first line,
295
+ // so every alarm this package ever sent arrived gutted. Same shape as a
296
+ // commit now: short label, full text quoted under it.
297
+ // Ярлык `Title`, а не `Reason`: у аварии заголовок — такой же заголовок,
298
+ // как у коммита и задачи, и называться в одной карточке он должен так же.
299
+ titleField(e.title),
300
+ e.detail && e.detail !== e.title ? note(e.detail) : null,
301
+ e.logs || e.url ? '' : null,
302
+ fieldCode('Logs', e.logs),
303
+ fieldAction('Workflow', e.url, undefined)
304
+ ]);
305
+ // Раньше всё это склеивалось в одну строку `Reason:` через тире: «имя — no
306
+ // reports — expected X, last seen Y». Каждая другая карточка кладёт факт на
307
+ // свою строку с ярлыком, и владелец справедливо спросил, зачем тут отдельный
308
+ // формат. Отдельного формата больше нет.
309
+ const renderHeartbeatMiss = (e) => {
310
+ const icon = e.recovered ? ICON.ok : ICON.red;
311
+ const action = e.recovered ? 'ok' : 'miss';
170
312
  return join([
171
- header(icon, `Задача #${e.number} ${verb}`, e.project),
172
- esc(e.title),
173
- kv('автор', e.author),
174
- kv('исполнитель', e.assignee),
175
- link(e.url, 'Открыть задачу')
313
+ typeLine(icon, 'Heartbeat', action),
314
+ '',
315
+ field('Task', e.job),
316
+ field('Reason', e.note),
317
+ field('Expected', e.expected),
318
+ field(e.recovered ? 'Last run' : 'Last seen', e.lastSeen)
176
319
  ]);
177
320
  };
178
- const renderIncident = (e) => join([header('🚨', 'Инцидент', e.project), esc(e.title), note(e.detail), link(e.url, 'Подробнее')]);
179
- const renderHeartbeatMiss = (e) => join([
180
- header('🔴', `Не отметилась: ${e.job}`, e.project),
181
- kv('последний раз', e.lastSeen),
182
- kv('ожидалось', e.expected)
183
- ]);
184
321
  // Подпись файла — та же карточка, но лимит Telegram у caption свой: 1024.
185
- const renderFile = (e) => join([header('📄', e.title, e.project), note(e.note)]);
322
+ const renderFile = (e) => join([
323
+ typeLine(ICON.info, 'File', 'new'),
324
+ '',
325
+ // Раньше здесь стояло `field('Title', e.note ?? e.title)`: подпись файла
326
+ // приходила под ярлыком заголовка, а сам заголовок из карточки исчезал.
327
+ // Один ярлык — один смысл: Title это title, Reason это note.
328
+ field('Title', e.title),
329
+ field('Reason', e.note)
330
+ ]);
186
331
  const RENDERERS = {
187
332
  deploy: renderDeploy,
188
333
  job: renderJob,
@@ -194,28 +339,47 @@ const RENDERERS = {
194
339
  heartbeat_miss: renderHeartbeatMiss,
195
340
  file: renderFile
196
341
  };
197
- /**
198
- * Ключ задачи последняя строка карточки: `#ключ` курсивом в <code>. Без
199
- * названия проекта: `targets()` никогда не шлёт карточку в чужой форум,
200
- * проект и так на виду в заголовке («· mac-config»), а дублирующий префикс
201
- * только растягивал тег на лишнюю строку в узком экране телефона (жалоба
202
- * владельца 18.08). Явный `key` побеждает; выведенный строится из заголовка
203
- * и наследует хрупкость формулировки регулярные отправители передают явный.
204
- * Ключ переживает MTProto-чтение (простой текст, не разметка), по нему
205
- * разборщик сверяет 🔴 с более поздней успешной карточкой той же задачи —
206
- * внутри чата одного проекта, где ключ и так уникален.
207
- */
342
+ // Тег наверху карточки И машинный ключ разборщика — ОДНО И ТО ЖЕ значение
343
+ // (решение владельца 20.08.2026): раньше это были два разных представления
344
+ // одного факта (снизу дефисный `#ci-arvent`, сверху теги вручную), и это
345
+ // читалось как дублирование. Разделитель подчёркивание, не дефис: дефис
346
+ // разрывает Telegram-хэштег на середине слова (`#mac-config` линкуется
347
+ // только как `#mac`), а тег ДОЛЖЕН быть кликабельным это и есть фильтр
348
+ // «показать всю историю этого экземпляра», которым владелец пользуется вживую.
208
349
  const slug = (raw) => raw
209
350
  .toLowerCase()
210
- .replace(/[^\p{L}\p{N}]+/gu, '-')
211
- .replace(/^-+|-+$/g, '')
212
- // Ключ — идентификатор, не пересказ: без среза тег из длинного заголовка
213
- // съедал бюджет caption до отрицательного, и slice с минусом возвращал
214
- // почти весь текст — Telegram отвечал постоянным 400, файл терялся.
351
+ .replace(/[^\p{L}\p{N}]+/gu, '_')
352
+ .replace(/^_+|_+$/g, '')
215
353
  .slice(0, 60);
354
+ // Тип-тег наверху — не буквальный `e.type`: `heartbeat_miss` читался бы как
355
+ // `#heartbeat_miss`, а видимый тип у владельца всегда просто `#heartbeat`
356
+ // (зелёная и красная карточки одного вида — один и тот же тип-тег).
357
+ const TYPE_TAG = {
358
+ deploy: 'deploy',
359
+ job: 'job',
360
+ report: 'report',
361
+ ci: 'ci',
362
+ pr: 'pr',
363
+ issue: 'issue',
364
+ incident: 'incident',
365
+ heartbeat_miss: 'heartbeat',
366
+ file: 'file'
367
+ };
368
+ /**
369
+ * Экземпляр-тег: что именно это конкретное событие (ветка, окружение,
370
+ * задача, номер) — по нему разборщик сверяет 🔴 с более поздней зелёной
371
+ * карточкой ТОГО ЖЕ экземпляра. Явный `key` побеждает всегда; без него —
372
+ * выводится из самых стабильных полей типа (ветка/окружение важнее заголовка,
373
+ * потому что заголовок у регулярной задачи не меняется, а у отчёта как раз
374
+ * заголовок и есть единственное стабильное поле).
375
+ */
216
376
  export const eventKey = (e) => {
217
377
  const fallback = () => {
218
378
  switch (e.type) {
379
+ case 'ci':
380
+ return slug(e.branch || e.project);
381
+ case 'deploy':
382
+ return slug(e.target || e.project);
219
383
  case 'job':
220
384
  case 'heartbeat_miss':
221
385
  return slug(e.job);
@@ -224,21 +388,20 @@ export const eventKey = (e) => {
224
388
  case 'file':
225
389
  return slug(e.title);
226
390
  case 'pr':
227
- return `pr-${e.number}`;
391
+ return `p${e.number}`;
228
392
  case 'issue':
229
- return `issue-${e.number}`;
230
- default:
231
- return e.type;
393
+ return `i${e.number}`;
232
394
  }
233
395
  };
234
396
  return e.key ? slug(e.key) : fallback();
235
397
  };
236
- const keyLine = (e) => `<i><code>#${esc(eventKey(e))}</code></i>`;
398
+ const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))}`;
237
399
  /**
238
400
  * Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
239
- * Ключ добавляется ПОСЛЕ обрезки, с зарезервированным местом: обрезанная
240
- * карточка без ключа была бы невидима разборщикуровно на самых длинных,
241
- * то есть самых важных сообщениях.
401
+ * Теги ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
402
+ * несут и человеческий фильтр, и машинный ключ разборщика обрезанная
403
+ * карточка без них была бы не только некликабельной, но и невидимой
404
+ * разборщику ровно на самых длинных, то есть самых важных сообщениях.
242
405
  */
243
406
  export const render = (e) => {
244
407
  const renderer = RENDERERS[e.type];
@@ -248,10 +411,10 @@ export const render = (e) => {
248
411
  if (typeof renderer !== 'function') {
249
412
  throw new Error(`неизвестный тип события: ${String(e.type)}`);
250
413
  }
251
- const tag = keyLine(e);
414
+ const tags = tagsLine(e);
252
415
  // clampMessage может выйти за переданный limit на хвост закрывающих тегов и
253
416
  // многоточие — минус 40 оставляет ему этот запас. У сообщений свой запас уже
254
417
  // есть (4000 против 4096 у Telegram), у caption лимит 1024 настоящий.
255
- const budget = Math.max(64, e.type === 'file' ? 1024 - tag.length - 40 : 4000 - tag.length - 1);
256
- return `${clampMessage(renderer(e), budget)}\n${tag}`;
418
+ const budget = Math.max(64, e.type === 'file' ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
419
+ return `${tags}\n${clampMessage(renderer(e), budget)}`;
257
420
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",