@mikitasazan/notify 1.4.2 → 1.4.3

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
@@ -106,7 +106,17 @@ export type NotifyEvent = Keyed & (
106
106
  expected?: string;
107
107
  /** Когда её видели в последний раз. */
108
108
  lastSeen?: string;
109
- stats?: Array<[label: string, value: string | number]>;
109
+ /**
110
+ * Цифры от отправителя. Третий элемент — ИМЯ ГРУППЫ, под которой строка
111
+ * встанет. Владелец шесть раз просил группы, и каждый раз отправитель
112
+ * уже пытался их изобразить подручным: скобками в ярлыке
113
+ * («GA4 users (sum of days)»), значком в начале строки (🆕 против ⚠),
114
+ * лишней строкой внизу. Группировать было нечем — теперь есть.
115
+ *
116
+ * Без третьего элемента строка идёт без заголовка, как раньше: все
117
+ * существующие отправители продолжают работать не меняясь.
118
+ */
119
+ stats?: Array<[label: string, value: string | number, group?: string]>;
110
120
  /** Детали: что именно упало, замечания прогона; у `disabled` — список выключенных процессов (каждый со своей ссылкой). */
111
121
  items?: Item[];
112
122
  note?: string;
@@ -145,7 +155,7 @@ export type NotifyEvent = Keyed & (
145
155
  title: string;
146
156
  period?: string;
147
157
  /** Пусто/не передано, когда используются `groups` — два вида отчёта не смешиваются в одном событии. */
148
- lines?: Array<[label: string, value: string | number]>;
158
+ lines?: Array<[label: string, value: string | number, group?: string]>;
149
159
  /**
150
160
  * Список позиций со ссылками — для дайджестов задач, где ценность в
151
161
  * самих названиях, а не в цифре. Рендерятся отдельным блоком после
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,7 +196,28 @@ 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
222
  const bullets = (items, numbered) => (items ?? []).map((it, i) => groupItem(it, i, numbered));
202
223
  /** Именованная группа целиком: заголовок + позиции, разделены строкой пустоты внутри вызова через join. */
@@ -219,6 +240,77 @@ const renderGroup = (g) => [
219
240
  */
220
241
  const titleField = (title) => field('Title', title);
221
242
  const bodyQuote = (body) => body ? note(body) : null;
243
+ /**
244
+ * Строки с ярлыками, разложенные по группам, которые назвал сам отправитель.
245
+ *
246
+ * Закон простой и считается программой: назвал группу — заголовок печатается.
247
+ * Всегда, сколько бы строк в ней ни было и сколько бы групп ни оказалось.
248
+ * Порог «две и больше» я пробовал и снял: у карточки резервных копий все
249
+ * цифры лежат в одной группе, а над ними — рассказ о прогоне, и порог гасил
250
+ * ровно тот шов, ради которого владелец всё это и просил.
251
+ *
252
+ * Так же уходит и риск «одна и та же карточка выглядит по-разному в разные
253
+ * дни»: вид зависит от того, что отправитель НАЗВАЛ в коде, а не от того,
254
+ * сколько строк набралось сегодня.
255
+ *
256
+ * Порядок групп — порядок первого появления у отправителя: он знает, что
257
+ * важнее. Строки без имени идут первыми и без заголовка — это факты о самой
258
+ * карточке, а не о каком-то из её предметов.
259
+ */
260
+ const labelled = (rows) => {
261
+ const list = rows ?? [];
262
+ const names = [...new Set(list.map(([, , g]) => g).filter((g) => !!g))];
263
+ if (names.length === 0) {
264
+ return list.map(([label, value]) => field(label, value)).filter((l) => l !== null);
265
+ }
266
+ const out = [];
267
+ const bare = list.filter(([, , g]) => !g);
268
+ for (const [label, value] of bare) {
269
+ const line = field(label, value);
270
+ if (line !== null) {
271
+ out.push(line);
272
+ }
273
+ }
274
+ for (const name of names) {
275
+ // Пустая строка перед КАЖДЫМ заголовком, включая первый: над ним всегда
276
+ // стоят поля самой карточки (Task, Period), и без шва заголовок читался
277
+ // как ещё одна их строка. Двойных пустот бояться не нужно — их схлопывает
278
+ // `join`.
279
+ out.push('');
280
+ out.push(group(name));
281
+ for (const [label, value] of list.filter(([, , g]) => g === name)) {
282
+ const line = field(label, value);
283
+ if (line !== null) {
284
+ out.push(line);
285
+ }
286
+ }
287
+ }
288
+ return out;
289
+ };
290
+ /**
291
+ * Блоки, которыми владеет сам рендерер, — у выкатки и проверки их два, и они
292
+ * про разные вещи: `Run` это сам прогон и его обстоятельства, `Change` это
293
+ * изменение, из-за которого он случился. Владелец на CI-карточке: «commit,
294
+ * actor, workflow — не знаю, всё так сумбурно».
295
+ *
296
+ * Заголовок печатается у КАЖДОГО непустого блока, а не только когда их два.
297
+ * Сначала было «два и больше», ради экономии строки на зелёной карточке, и
298
+ * это оказалось ошибкой: у зелёной выкатки нет ни цели, ни причины, блок один,
299
+ * заголовки пропадали — и один и тот же вид уведомления выглядел в разные дни
300
+ * по-разному. Владелец дважды спросил «почему здесь нет групп», глядя именно
301
+ * на зелёную. Строка заголовка стоит дешевле, чем необходимость каждый раз
302
+ * заново искать глазами, где что.
303
+ */
304
+ const twoBlocks = (run, change) => {
305
+ const live = (rows) => rows.filter((r) => r !== null && r !== '');
306
+ const out = [];
307
+ for (const [name, rows] of [['Run', live(run)], ['Change', live(change)]]) {
308
+ if (rows.length > 0) {
309
+ out.push('', group(name), ...rows);
310
+ }
311
+ }
312
+ return out;
313
+ };
222
314
  // Значок и его закон живут в events.ts: от него зависит и звук.
223
315
  /** Строка 2: значок вне жирного, `<b>Тип:</b> действие` — то же поле, не особый случай. */
224
316
  // `action` объявлен строкой, но приходит и из `--json`, и из прямых вызовов на
@@ -272,12 +364,7 @@ const renderDeploy = (e) => {
272
364
  return join([
273
365
  typeLine(icon, 'Deploy', e.status),
274
366
  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)
367
+ ...twoBlocks([field('Target', e.target), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
281
368
  ]);
282
369
  };
283
370
  const renderJob = (e) => {
@@ -301,7 +388,7 @@ const renderJob = (e) => {
301
388
  // `Last run` when the task is alive, `Last seen` when it is not: the same
302
389
  // timestamp answers two different questions.
303
390
  field(e.status === 'silent' ? 'Last seen' : 'Last run', e.lastSeen),
304
- ...(e.stats ?? []).map(([label, value]) => field(label, value)),
391
+ ...labelled(e.stats),
305
392
  hasItems ? '' : null,
306
393
  // Heading ONLY for `disabled`. It used to print for any job carrying a
307
394
  // list, so playhub's daily card of newly published games was headed
@@ -323,11 +410,15 @@ const renderReport = (e) => {
323
410
  // `lines` и `groups` вместе, а не «или»: раньше ветка с группами печатала
324
411
  // ТОЛЬКО группы, и цифры отчёта молча исчезали. Поймано 25.08.2026 при
325
412
  // переводе утреннего отчёта PlayHub на типизированное событие.
326
- const numbers = (e.lines ?? []).map(([label, value]) => field(label, value));
413
+ const numbers = labelled(e.lines);
327
414
  return join([
328
415
  typeLine(iconFor(e), 'Report', e.title, e.url),
329
- '',
416
+ // Период стоит ВПЛОТНУЮ к названию, без пустой строки, по тому же
417
+ // закону, что `Via` у выкатки и `Check` у проверки: строка, которая
418
+ // уточняет вторую строку, живёт рядом с ней, а не в блоке фактов.
419
+ // Владелец: «период пошёл не туда, он же должен быть рядом с датой».
330
420
  field('Period', e.period),
421
+ '',
331
422
  ...numbers,
332
423
  body.length > 0 ? '' : null,
333
424
  ...body
@@ -338,11 +429,10 @@ const renderReport = (e) => {
338
429
  // Both analytics jobs send a link to the day's snapshot in docs/. It used to
339
430
  // hang off a trailing `Details: open` row; now it is the report's own name.
340
431
  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.
432
+ // Вплотную к названию — см. соседнюю ветку.
344
433
  field('Period', e.period),
345
- ...(e.lines ?? []).map(([label, value]) => field(label, value)),
434
+ '',
435
+ ...labelled(e.lines),
346
436
  items.length > 0 ? '' : null,
347
437
  ...items
348
438
  ]);
@@ -357,12 +447,7 @@ const renderCi = (e) => {
357
447
  return join([
358
448
  typeLine(icon, 'CI', e.status),
359
449
  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)
450
+ ...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
366
451
  ]);
367
452
  };
368
453
  const renderPr = (e) => join([
@@ -504,7 +589,28 @@ export const eventKey = (e) => {
504
589
  };
505
590
  return e.key ? slug(e.key) : fallback();
506
591
  };
507
- const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))}`;
592
+ /**
593
+ * Третий тег — ИСХОД, и он есть всегда. Владелец: «не хватает тега fail или
594
+ * похожего, чтобы фейлы можно было группировать и ок можно было группировать».
595
+ * Одно нажатие в Telegram собирает все падения проекта разом, каким бы типом
596
+ * они ни пришли — выкатка, проверка, задача по расписанию, авария.
597
+ *
598
+ * Значение берётся у ЗНАЧКА, а не у слова статуса, и это не мелочь: значок уже
599
+ * единственный источник правды про звук, и второй список «что считать
600
+ * падением» разошёлся бы с первым — так уже было, когда красная карточка
601
+ * приходила беззвучной. Громкий значок — `#fail`, зелёный — `#ok`, всё
602
+ * остальное (завели задачу, открыли PR, попросили правки, отчёт) — `#news`:
603
+ * это новость, а не приговор робота.
604
+ */
605
+ const OK_ICONS = new Set([ICON.ok, ICON.landed, ICON.approved]);
606
+ const outcomeTag = (e) => {
607
+ const icon = iconFor(e);
608
+ if (LOUD.has(icon)) {
609
+ return 'fail';
610
+ }
611
+ return OK_ICONS.has(icon) ? 'ok' : 'news';
612
+ };
613
+ const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
508
614
  /**
509
615
  * Строка тегов для свободного HTML (`sendReport`). Тег — это ФИЛЬТР владельца,
510
616
  * и к формату тела он отношения не имеет: дневной отчёт остаётся свободным
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
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",