@mikitasazan/notify 1.4.2 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -106,9 +106,23 @@ const items = () => (flags.get('item') ?? []).map((raw) => {
106
106
  const idx = raw.lastIndexOf('|');
107
107
  return idx === -1 ? { text: raw } : { text: raw.slice(0, idx), url: raw.slice(idx + 1) };
108
108
  });
109
+ /**
110
+ * `--stat "label=value"`, и с 25.08.2026 — `--stat "Group | label=value"`:
111
+ * имя группы, вертикальная черта, ярлык. Черта выбрана потому, что её нет ни
112
+ * в одном живом ярлыке, а двоеточие есть («Eval: bot answer quality») и
113
+ * равенство занято значением. Пробелы вокруг черты необязательны.
114
+ *
115
+ * Без черты всё как было — так шлют больше двадцати отправителей, и ни один
116
+ * из них менять не нужно.
117
+ */
109
118
  const pairs = (key) => (flags.get(key) ?? []).map((s) => {
110
119
  const idx = s.indexOf('=');
111
- return idx === -1 ? [s, ''] : [s.slice(0, idx), s.slice(idx + 1)];
120
+ const head = idx === -1 ? s : s.slice(0, idx);
121
+ const value = idx === -1 ? '' : s.slice(idx + 1);
122
+ const bar = head.indexOf('|');
123
+ return bar === -1
124
+ ? [head, value]
125
+ : [head.slice(bar + 1).trim(), value, head.slice(0, bar).trim()];
112
126
  });
113
127
  const project = () => one('project');
114
128
  const PR_ALIASES = {
package/dist/events.d.ts CHANGED
@@ -45,10 +45,19 @@ type Keyed = {
45
45
  * рендерится как обычная нумерованная/маркированная строка — так уже
46
46
  * работают дайджест-задачи и список выключенных workflow.
47
47
  */
48
+ /**
49
+ * `group` — имя блока, под которым позиция встанет. Тот же закон, что у
50
+ * `lines` и `stats`: без имени позиция идёт в общий список, как раньше.
51
+ *
52
+ * Заведено потому, что список импорта смешивал три РАЗНЫЕ вещи в одном
53
+ * перечне и различал их значком в начале строки: 🆕 вышло сегодня,
54
+ * 🔁 вышло из очереди, ⚠ не вышло совсем. Значок делал работу заголовка.
55
+ */
48
56
  export type Item = {
49
57
  text: string;
50
58
  url?: string;
51
59
  label?: string;
60
+ group?: string;
52
61
  };
53
62
  export type NotifyEvent = Keyed & (
54
63
  /** Выкатка кода на сервер. */
@@ -106,7 +115,17 @@ export type NotifyEvent = Keyed & (
106
115
  expected?: string;
107
116
  /** Когда её видели в последний раз. */
108
117
  lastSeen?: string;
109
- stats?: Array<[label: string, value: string | number]>;
118
+ /**
119
+ * Цифры от отправителя. Третий элемент — ИМЯ ГРУППЫ, под которой строка
120
+ * встанет. Владелец шесть раз просил группы, и каждый раз отправитель
121
+ * уже пытался их изобразить подручным: скобками в ярлыке
122
+ * («GA4 users (sum of days)»), значком в начале строки (🆕 против ⚠),
123
+ * лишней строкой внизу. Группировать было нечем — теперь есть.
124
+ *
125
+ * Без третьего элемента строка идёт без заголовка, как раньше: все
126
+ * существующие отправители продолжают работать не меняясь.
127
+ */
128
+ stats?: Array<[label: string, value: string | number, group?: string]>;
110
129
  /** Детали: что именно упало, замечания прогона; у `disabled` — список выключенных процессов (каждый со своей ссылкой). */
111
130
  items?: Item[];
112
131
  note?: string;
@@ -145,7 +164,7 @@ export type NotifyEvent = Keyed & (
145
164
  title: string;
146
165
  period?: string;
147
166
  /** Пусто/не передано, когда используются `groups` — два вида отчёта не смешиваются в одном событии. */
148
- lines?: Array<[label: string, value: string | number]>;
167
+ lines?: Array<[label: string, value: string | number, group?: string]>;
149
168
  /**
150
169
  * Список позиций со ссылками — для дайджестов задач, где ценность в
151
170
  * самих названиях, а не в цифре. Рендерятся отдельным блоком после
package/dist/render.js CHANGED
@@ -19,7 +19,7 @@
19
19
  * строка разделяет БЛОКИ ПО СМЫСЛУ (шапка / суть / действия), не механически
20
20
  * после каждой строки.
21
21
  */
22
- import { iconFor } from "./events.js";
22
+ import { ICON, LOUD, iconFor } from "./events.js";
23
23
  /** Первая буква — заглавная, остальное как есть (ga4/GitHub остаются собой). */
24
24
  /**
25
25
  * Ярлык с большой буквы — но НЕ у имени, которое пишется со строчной нарочно:
@@ -196,9 +196,50 @@ const note = (text) => {
196
196
  * держит одну строку и обрезает.
197
197
  */
198
198
  const quoted = (label, text) => text ? `<b>${esc(cap(label))}</b>\n${note(text)}` : null;
199
- const join = (parts) => parts.filter((p) => p !== null).join('\n');
199
+ /**
200
+ * Склейка карточки. Пустая строка здесь — знак смены блока, а не отступ:
201
+ * две подряд означают пустой блок, ведущая — блок, которого нет. Обе
202
+ * появляются, когда часть полей не пришла, и обе схлопываются тут, а не
203
+ * в каждом рендерере по отдельности.
204
+ */
205
+ const join = (parts) => {
206
+ const out = [];
207
+ for (const part of parts) {
208
+ if (part === null) {
209
+ continue;
210
+ }
211
+ if (part === '' && (out.length === 0 || out[out.length - 1] === '')) {
212
+ continue;
213
+ }
214
+ out.push(part);
215
+ }
216
+ while (out.length > 0 && out[out.length - 1] === '') {
217
+ out.pop();
218
+ }
219
+ return out.join('\n');
220
+ };
200
221
  /** Плоский список позиций (без ярлыков) — job/report без групп. */
201
- const bullets = (items, numbered) => (items ?? []).map((it, i) => groupItem(it, i, numbered));
222
+ /**
223
+ * Позиции списка. Именованная группа ВСЕГДА печатает свой заголовок — тот же
224
+ * закон, что у `labelled`: карточка одного типа не должна выглядеть по-разному
225
+ * в разные дни. Нумерация идёт внутри блока, а не сквозная: «1, 2» под своим
226
+ * заголовком читается, сквозная «3, 4» под вторым — нет.
227
+ */
228
+ const bullets = (items, numbered) => {
229
+ const list = items ?? [];
230
+ const names = [...new Set(list.map((it) => it.group).filter((g) => !!g))];
231
+ if (names.length === 0) {
232
+ return list.map((it, i) => groupItem(it, i, numbered));
233
+ }
234
+ const out = [];
235
+ list.filter((it) => !it.group).forEach((it, i) => out.push(groupItem(it, i, numbered)));
236
+ for (const name of names) {
237
+ out.push('');
238
+ out.push(group(name));
239
+ list.filter((it) => it.group === name).forEach((it, i) => out.push(groupItem(it, i, numbered)));
240
+ }
241
+ return out;
242
+ };
202
243
  /** Именованная группа целиком: заголовок + позиции, разделены строкой пустоты внутри вызова через join. */
203
244
  const renderGroup = (g) => [
204
245
  group(g.name),
@@ -219,6 +260,77 @@ const renderGroup = (g) => [
219
260
  */
220
261
  const titleField = (title) => field('Title', title);
221
262
  const bodyQuote = (body) => body ? note(body) : null;
263
+ /**
264
+ * Строки с ярлыками, разложенные по группам, которые назвал сам отправитель.
265
+ *
266
+ * Закон простой и считается программой: назвал группу — заголовок печатается.
267
+ * Всегда, сколько бы строк в ней ни было и сколько бы групп ни оказалось.
268
+ * Порог «две и больше» я пробовал и снял: у карточки резервных копий все
269
+ * цифры лежат в одной группе, а над ними — рассказ о прогоне, и порог гасил
270
+ * ровно тот шов, ради которого владелец всё это и просил.
271
+ *
272
+ * Так же уходит и риск «одна и та же карточка выглядит по-разному в разные
273
+ * дни»: вид зависит от того, что отправитель НАЗВАЛ в коде, а не от того,
274
+ * сколько строк набралось сегодня.
275
+ *
276
+ * Порядок групп — порядок первого появления у отправителя: он знает, что
277
+ * важнее. Строки без имени идут первыми и без заголовка — это факты о самой
278
+ * карточке, а не о каком-то из её предметов.
279
+ */
280
+ const labelled = (rows) => {
281
+ const list = rows ?? [];
282
+ const names = [...new Set(list.map(([, , g]) => g).filter((g) => !!g))];
283
+ if (names.length === 0) {
284
+ return list.map(([label, value]) => field(label, value)).filter((l) => l !== null);
285
+ }
286
+ const out = [];
287
+ const bare = list.filter(([, , g]) => !g);
288
+ for (const [label, value] of bare) {
289
+ const line = field(label, value);
290
+ if (line !== null) {
291
+ out.push(line);
292
+ }
293
+ }
294
+ for (const name of names) {
295
+ // Пустая строка перед КАЖДЫМ заголовком, включая первый: над ним всегда
296
+ // стоят поля самой карточки (Task, Period), и без шва заголовок читался
297
+ // как ещё одна их строка. Двойных пустот бояться не нужно — их схлопывает
298
+ // `join`.
299
+ out.push('');
300
+ out.push(group(name));
301
+ for (const [label, value] of list.filter(([, , g]) => g === name)) {
302
+ const line = field(label, value);
303
+ if (line !== null) {
304
+ out.push(line);
305
+ }
306
+ }
307
+ }
308
+ return out;
309
+ };
310
+ /**
311
+ * Блоки, которыми владеет сам рендерер, — у выкатки и проверки их два, и они
312
+ * про разные вещи: `Run` это сам прогон и его обстоятельства, `Change` это
313
+ * изменение, из-за которого он случился. Владелец на CI-карточке: «commit,
314
+ * actor, workflow — не знаю, всё так сумбурно».
315
+ *
316
+ * Заголовок печатается у КАЖДОГО непустого блока, а не только когда их два.
317
+ * Сначала было «два и больше», ради экономии строки на зелёной карточке, и
318
+ * это оказалось ошибкой: у зелёной выкатки нет ни цели, ни причины, блок один,
319
+ * заголовки пропадали — и один и тот же вид уведомления выглядел в разные дни
320
+ * по-разному. Владелец дважды спросил «почему здесь нет групп», глядя именно
321
+ * на зелёную. Строка заголовка стоит дешевле, чем необходимость каждый раз
322
+ * заново искать глазами, где что.
323
+ */
324
+ const twoBlocks = (run, change) => {
325
+ const live = (rows) => rows.filter((r) => r !== null && r !== '');
326
+ const out = [];
327
+ for (const [name, rows] of [['Run', live(run)], ['Change', live(change)]]) {
328
+ if (rows.length > 0) {
329
+ out.push('', group(name), ...rows);
330
+ }
331
+ }
332
+ return out;
333
+ };
222
334
  // Значок и его закон живут в events.ts: от него зависит и звук.
223
335
  /** Строка 2: значок вне жирного, `<b>Тип:</b> действие` — то же поле, не особый случай. */
224
336
  // `action` объявлен строкой, но приходит и из `--json`, и из прямых вызовов на
@@ -272,12 +384,7 @@ const renderDeploy = (e) => {
272
384
  return join([
273
385
  typeLine(icon, 'Deploy', e.status),
274
386
  fieldLink('Via', runUrl, mechanism(e.workflowName, e.via, runUrl)),
275
- '',
276
- fieldLink('Commit', e.commitUrl, e.commit),
277
- titleField(e.commitTitle),
278
- bodyQuote(e.commitBody),
279
- field('Target', e.target),
280
- field('Reason', e.note)
387
+ ...twoBlocks([field('Target', e.target), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
281
388
  ]);
282
389
  };
283
390
  const renderJob = (e) => {
@@ -301,7 +408,7 @@ const renderJob = (e) => {
301
408
  // `Last run` when the task is alive, `Last seen` when it is not: the same
302
409
  // timestamp answers two different questions.
303
410
  field(e.status === 'silent' ? 'Last seen' : 'Last run', e.lastSeen),
304
- ...(e.stats ?? []).map(([label, value]) => field(label, value)),
411
+ ...labelled(e.stats),
305
412
  hasItems ? '' : null,
306
413
  // Heading ONLY for `disabled`. It used to print for any job carrying a
307
414
  // list, so playhub's daily card of newly published games was headed
@@ -323,11 +430,15 @@ const renderReport = (e) => {
323
430
  // `lines` и `groups` вместе, а не «или»: раньше ветка с группами печатала
324
431
  // ТОЛЬКО группы, и цифры отчёта молча исчезали. Поймано 25.08.2026 при
325
432
  // переводе утреннего отчёта PlayHub на типизированное событие.
326
- const numbers = (e.lines ?? []).map(([label, value]) => field(label, value));
433
+ const numbers = labelled(e.lines);
327
434
  return join([
328
435
  typeLine(iconFor(e), 'Report', e.title, e.url),
329
- '',
436
+ // Период стоит ВПЛОТНУЮ к названию, без пустой строки, по тому же
437
+ // закону, что `Via` у выкатки и `Check` у проверки: строка, которая
438
+ // уточняет вторую строку, живёт рядом с ней, а не в блоке фактов.
439
+ // Владелец: «период пошёл не туда, он же должен быть рядом с датой».
330
440
  field('Period', e.period),
441
+ '',
331
442
  ...numbers,
332
443
  body.length > 0 ? '' : null,
333
444
  ...body
@@ -338,11 +449,10 @@ const renderReport = (e) => {
338
449
  // Both analytics jobs send a link to the day's snapshot in docs/. It used to
339
450
  // hang off a trailing `Details: open` row; now it is the report's own name.
340
451
  typeLine(iconFor(e), 'Report', e.title, e.url),
341
- '',
342
- // The period used to ride on the type line after a middot, which made the
343
- // clickable name longer than the name and put two facts on one line.
452
+ // Вплотную к названию — см. соседнюю ветку.
344
453
  field('Period', e.period),
345
- ...(e.lines ?? []).map(([label, value]) => field(label, value)),
454
+ '',
455
+ ...labelled(e.lines),
346
456
  items.length > 0 ? '' : null,
347
457
  ...items
348
458
  ]);
@@ -357,12 +467,7 @@ const renderCi = (e) => {
357
467
  return join([
358
468
  typeLine(icon, 'CI', e.status),
359
469
  fieldLink('Check', runUrl, mechanism(e.workflowName, undefined, runUrl)),
360
- '',
361
- fieldLink('Commit', e.commitUrl, e.commit),
362
- titleField(e.commitTitle),
363
- bodyQuote(e.commitBody),
364
- field('Actor', e.actor),
365
- field('Reason', e.note)
470
+ ...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
366
471
  ]);
367
472
  };
368
473
  const renderPr = (e) => join([
@@ -504,7 +609,28 @@ export const eventKey = (e) => {
504
609
  };
505
610
  return e.key ? slug(e.key) : fallback();
506
611
  };
507
- const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))}`;
612
+ /**
613
+ * Третий тег — ИСХОД, и он есть всегда. Владелец: «не хватает тега fail или
614
+ * похожего, чтобы фейлы можно было группировать и ок можно было группировать».
615
+ * Одно нажатие в Telegram собирает все падения проекта разом, каким бы типом
616
+ * они ни пришли — выкатка, проверка, задача по расписанию, авария.
617
+ *
618
+ * Значение берётся у ЗНАЧКА, а не у слова статуса, и это не мелочь: значок уже
619
+ * единственный источник правды про звук, и второй список «что считать
620
+ * падением» разошёлся бы с первым — так уже было, когда красная карточка
621
+ * приходила беззвучной. Громкий значок — `#fail`, зелёный — `#ok`, всё
622
+ * остальное (завели задачу, открыли PR, попросили правки, отчёт) — `#news`:
623
+ * это новость, а не приговор робота.
624
+ */
625
+ const OK_ICONS = new Set([ICON.ok, ICON.landed, ICON.approved]);
626
+ const outcomeTag = (e) => {
627
+ const icon = iconFor(e);
628
+ if (LOUD.has(icon)) {
629
+ return 'fail';
630
+ }
631
+ return OK_ICONS.has(icon) ? 'ok' : 'news';
632
+ };
633
+ const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
508
634
  /**
509
635
  * Строка тегов для свободного HTML (`sendReport`). Тег — это ФИЛЬТР владельца,
510
636
  * и к формату тела он отношения не имеет: дневной отчёт остаётся свободным
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,8 @@
33
33
  "build": "tsc -p tsconfig.build.json",
34
34
  "prepare": "tsc -p tsconfig.build.json",
35
35
  "typecheck": "tsc --noEmit -p tsconfig.json",
36
- "test": "node --test src/*.test.ts"
36
+ "test": "node --test src/*.test.ts",
37
+ "catalogue": "npm run build && node catalogue/build.mjs && node catalogue/assemble.mjs"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@types/node": "^24.13.3",