@mikitasazan/notify 1.8.1 → 1.9.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.
package/README.md CHANGED
@@ -95,17 +95,18 @@ notify report --project playhub --json < payload.json # весь объект
95
95
 
96
96
  Полные сигнатуры — `src/events.ts`.
97
97
 
98
- **Ключ задачи.** Первая строка каждой карточки — два тега: `#тип #ключ`.
98
+ **Ключ задачи.** Первая строка каждой карточки — три тега: `#тип #ключ
99
+ #итог`.
99
100
  По ним дневной разборщик сверяет «это 🔴 уже закрыто более поздней карточкой
100
101
  той же задачи?» без сравнения человеческих формулировок. Явный `--key`
101
102
  побеждает; без него ключ выводится из заголовка и меняется вместе с ним —
102
103
  регулярный отправитель передаёт `--key` явно. В stdout CLI ключ не попадает.
103
104
 
104
- **Единственная дверь для свободного HTML** `sendReport()` (`report --json` у
105
- аналитик): плотную строку дневного отчёта не разложить в `label=value` не
106
- испортив. Стандартизирован транспорт, формат тела нет, и только здесь.
107
- Строку тегов `#report #ключ` пакет ставит и здесь: тег — фильтр владельца, к
108
- формату тела он отношения не имеет. Без явного ключа берётся `daily`.
105
+ **Свободного HTML в пакете нет.** Дверь `sendReport()` удалена 25.08.2026:
106
+ она отдавала произвольную разметку и ставила карточке два тега вместо трёх —
107
+ единственный вид, который нельзя было отфильтровать по итогу. Последний её
108
+ отправитель перешёл на типизированный `report` ещё 25.08; тип события задаёт
109
+ каждый знак карточки, других путей нет.
109
110
 
110
111
  **Неизвестный проект** не роняет вызвавший крон (код возврата 0), но больше и
111
112
  не исчезает молча: в mac-config Ops уходит красная карточка «notify: событие
package/dist/events.js CHANGED
@@ -68,7 +68,11 @@ export const iconFor = (e) => {
68
68
  case 'ci':
69
69
  return e.status === 'ok' ? ICON.ok : ICON.red;
70
70
  case 'job':
71
- return JOB_ICON[e.status];
71
+ // `?? ICON.unknown`: an untyped `--json` payload can carry a status
72
+ // outside the map, and an undefined icon printed the literal word
73
+ // `undefined` at the head of line 2. Not knowing is itself a state the
74
+ // package already has a word and a sound for.
75
+ return JOB_ICON[e.status] ?? ICON.unknown;
72
76
  case 'session':
73
77
  return e.status === 'ok' ? ICON.ok : ICON.alarm;
74
78
  case 'incident':
@@ -76,9 +80,9 @@ export const iconFor = (e) => {
76
80
  case 'heartbeat_miss':
77
81
  return e.recovered ? ICON.ok : ICON.unknown;
78
82
  case 'pr':
79
- return PR_ICON[e.action];
83
+ return PR_ICON[e.action] ?? ICON.unknown;
80
84
  case 'issue':
81
- return ISSUE_ICON[e.action];
85
+ return ISSUE_ICON[e.action] ?? ICON.unknown;
82
86
  case 'report':
83
87
  return ICON.info;
84
88
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type { EventType, NotifyEvent, Project } from './events.ts';
2
2
  export { severity } from './events.ts';
3
- export { notify, sendReport } from './send.ts';
3
+ export { notify } from './send.ts';
4
4
  export { render } from './render.ts';
5
5
  export { trend } from './trend.ts';
6
6
  export type { SendResult } from './send.ts';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { severity } from "./events.js";
2
- export { notify, sendReport } from "./send.js";
2
+ export { notify } from "./send.js";
3
3
  // `render` is exported so a sender can put on disk EXACTLY the card that was
4
4
  // sent rather than its own second version of the text. A hand-built copy had
5
5
  // already drifted from what went out.
package/dist/render.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * separates BLOCKS BY MEANING (header / body / actions), not mechanically
20
20
  * after every line.
21
21
  */
22
- import { type NotifyEvent } from './events.ts';
22
+ import { ICON, type NotifyEvent } from './events.ts';
23
23
  /** Escapes EVERYTHING that comes from outside — only the template adds tags. */
24
24
  export declare const esc: (v: unknown) => string;
25
25
  /**
@@ -65,16 +65,20 @@ export declare const eventKey: (e: NotifyEvent) => string;
65
65
  * `#fail` under a 🚫 and said so. Nor is a task that has simply gone quiet —
66
66
  * nobody knows yet whether it broke, and `#unknown` is the honest word for it.
67
67
  */
68
- export declare const OUTCOME_TAG: Readonly<Record<string, string>>;
69
- export declare const outcomeTag: (e: NotifyEvent) => string;
68
+ /** Every icon the package can print — the key space of the outcome table. */
69
+ type Icon = (typeof ICON)[keyof typeof ICON];
70
+ /** The five words the third tag is allowed to be, and there is no sixth. */
71
+ type OutcomeTag = 'ok' | 'fail' | 'off' | 'unknown' | 'news';
70
72
  /**
71
- * The tag line for free-form HTML (`sendReport`). The tag is the owner's
72
- * FILTER, and it has nothing to do with the body's format: a daily report
73
- * stays free-form text, but stops being the one card with no tags. The key
74
- * used to hang off the tail as `<i><code>#key</code></i>` that is the old
75
- * format, from before tags moved to the first line.
73
+ * The type is `Record` over EVERY icon, not over `string`. A new icon added to
74
+ * `ICON` without a word here now fails to compile. Under the old loose type it
75
+ * fell through a `?? 'news'` default instead: a card whose outcome nobody had
76
+ * decided was indistinguishable from a card that is genuinely just news, and
77
+ * nothing anywhere went red. `news` is therefore written out for each icon
78
+ * that means it, never left to a fallback.
76
79
  */
77
- export declare const reportTags: (key: string) => string;
80
+ export declare const OUTCOME_TAG: Readonly<Record<Icon, OutcomeTag>>;
81
+ export declare const outcomeTag: (e: NotifyEvent) => OutcomeTag;
78
82
  /**
79
83
  * Renders an event into finished HTML text, cut to Telegram's limit.
80
84
  * Tags are the FIRST line, added before the cut (not after, as before):
@@ -83,3 +87,4 @@ export declare const reportTags: (key: string) => string;
83
87
  * parser on exactly the longest, meaning the most important, messages.
84
88
  */
85
89
  export declare const render: (e: NotifyEvent) => string;
90
+ export {};
package/dist/render.js CHANGED
@@ -383,10 +383,20 @@ const typeLine = (icon, type, action, url, aside) => {
383
383
  // template prints the word "null". That is how line 2 of a card became
384
384
  // `ℹ️ null` — reachable through `--json` and through a direct call from JS,
385
385
  // where there are no types.
386
- // `action || 'open'` in the linked case: an empty title must not swallow the
387
- // link, which would be the one thing the card cannot afford to lose.
388
- const line = url ? fieldLink(type, url, action || 'open') : field(type, action);
389
386
  const tail = aside ? ` (${esc(aside)})` : '';
387
+ const name = action?.trim();
388
+ // No name: the card must not invent one. It used to write the word `open`
389
+ // into the identifier slot, so a report with a link and no title arrived as
390
+ // `Report: open` — and a deploy with neither fell back to its own status
391
+ // word, `Deploy: fail`, saying the outcome a third time after the icon and
392
+ // the tag. Both are the same mistake: a slot that must hold a name holding
393
+ // something else instead. The type word itself takes the link, so nothing
394
+ // clickable is lost and nothing false is said.
395
+ if (!name) {
396
+ const bare = `<b>${esc(cap(type))}</b>`;
397
+ return `${icon} ${url ? `<a href="${esc(url)}">${bare}</a>` : bare}${tail}`;
398
+ }
399
+ const line = url ? fieldLink(type, url, name) : field(type, name);
390
400
  return line === null ? `${icon} <b>${esc(cap(type))}</b>${tail}` : `${icon} ${line}${tail}`;
391
401
  };
392
402
  // `workflowUrl ?? url`: half the senders send the run link under the name
@@ -405,7 +415,7 @@ const typeLine = (icon, type, action, url, aside) => {
405
415
  * GitHub Action always fills the workflow name, and the hand-run scripts send
406
416
  * no run link at all.
407
417
  */
408
- const mechanism = (workflowName, via, runUrl) => workflowName ?? via ?? (runUrl ? 'the run' : undefined);
418
+ const mechanism = (workflowName, via, runUrl) => workflowName ?? via;
409
419
  // The name of what ran sits WITH the type line, not eight lines below it.
410
420
  // `Deploy: fail` and `by what means it ran` answer one question, and the owner
411
421
  // read the two rows as unrelated things. It used to be one fact split in two:
@@ -426,7 +436,7 @@ const renderDeploy = (e) => {
426
436
  // is already said by the icon and the third tag; there is nothing to
427
437
  // repeat in words, and it is the same law a job and a report follow. The
428
438
  // `Via` row is gone: it used to carry this same name one floor below.
429
- typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via, runUrl) ?? e.status, runUrl),
439
+ typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via, runUrl), runUrl),
430
440
  ...twoBlocks([field('Target', e.target), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
431
441
  ]);
432
442
  };
@@ -465,11 +475,11 @@ const renderJob = (e) => {
465
475
  ...(hasItems ? bullets(e.items, disabledList) : []),
466
476
  e.command || e.logs ? '' : null,
467
477
  fieldCode('Log', e.logs),
468
- ...fieldRun(e.command, e.commandNote),
469
- // Kept only when the caller actually names the workflowa named row is a
470
- // second, different destination; an unnamed one repeats the Task link.
471
- e.workflowName && (e.workflowUrl ?? e.url) ? '' : null,
472
- e.workflowName ? fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName) : null
478
+ ...fieldRun(e.command, e.commandNote)
479
+ // No trailing `Workflow:` row. It pointed at `workflowUrl ?? url` the
480
+ // exact address line 2 already carries so it was one destination
481
+ // written twice, and it stood BELOW the `To do:` command, which is the
482
+ // last thing the card is supposed to say.
473
483
  ]);
474
484
  };
475
485
  const renderReport = (e) => {
@@ -508,7 +518,7 @@ const renderCi = (e) => {
508
518
  const icon = iconFor(e);
509
519
  const runUrl = e.workflowUrl ?? e.url;
510
520
  return join([
511
- typeLine(icon, 'CI', mechanism(e.workflowName, undefined, runUrl) ?? e.status, runUrl),
521
+ typeLine(icon, 'CI', mechanism(e.workflowName, undefined, runUrl), runUrl),
512
522
  ...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
513
523
  ]);
514
524
  };
@@ -554,7 +564,7 @@ const renderIncident = (e) => join([
554
564
  typeLine(iconFor(e), 'Incident', e.title, e.url),
555
565
  e.detail && e.detail !== e.title ? note(e.detail) : null,
556
566
  e.logs ? '' : null,
557
- fieldCode('Logs', e.logs)
567
+ fieldCode('Log', e.logs)
558
568
  ]);
559
569
  // A session in trouble. Same law as every other card: identifier first, then
560
570
  // the facts as fields, then his own words as a quote — never as a field, which
@@ -578,14 +588,16 @@ const renderSession = (e) => join([
578
588
  ]);
579
589
  const renderHeartbeatMiss = (e) => {
580
590
  const icon = iconFor(e);
581
- const action = e.recovered ? 'ok' : 'miss';
582
591
  return join([
583
592
  // The task's name on the type line, exactly as a job card carries it. The
584
593
  // `Task:` row said the same thing a floor below. No sender in any
585
594
  // repository builds this event any more — the silence watchdog sends an
586
595
  // ordinary job with `--status silent` — but a machine still running the old
587
596
  // copy of that watchdog can, and the card it gets must obey the template.
588
- typeLine(icon, 'Heartbeat', e.job, undefined, action),
597
+ // No bracket saying `ok` or `miss`: the icon says it, the third tag says
598
+ // it, and `miss` is not one of the five words the outcome is allowed to
599
+ // be. The bracket is for what finishes the NAME, never for a verdict.
600
+ typeLine(icon, 'Heartbeat', e.job),
589
601
  field('Reason', e.note),
590
602
  ...schedule(e.expected, e.lastSeen, e.recovered ? 'Last run' : 'Last seen')
591
603
  ]);
@@ -660,24 +672,19 @@ export const eventKey = (e) => {
660
672
  return `i${e.number}`;
661
673
  }
662
674
  };
663
- return e.key ? slug(e.key) : fallback();
675
+ // An empty instance tag (`#session # #fail`) is not a tag: it groups
676
+ // nothing and the parser cannot pair a red card with its green one. An
677
+ // untyped `--json` payload can leave every field it is derived from blank,
678
+ // so the project name is the last resort.
679
+ return (e.key ? slug(e.key) : fallback()) || slug(e.project) || 'event';
664
680
  };
665
681
  /**
666
- * The third tag is the OUTCOME, and it is always there. The owner: "I'm
667
- * missing a fail tag or something like it, so failures can be grouped and
668
- * ok can be grouped." One tap in Telegram collects every failure of a
669
- * project at once, no matter what type it arrived as — a deploy, a check,
670
- * a scheduled task, an incident.
671
- *
672
- * The value comes from the ICON, never from the status word. The icon is
673
- * already the single source of truth for the sound, and a second list of "what
674
- * counts as broken" would drift from the first — it already did once, when a
675
- * red card arrived silent.
676
- *
677
- * One icon meaning, one tag. A watchdog that SWITCHED SOMETHING OFF is not a
678
- * failure and must not be filed under the same word as one: the owner read
679
- * `#fail` under a 🚫 and said so. Nor is a task that has simply gone quiet —
680
- * nobody knows yet whether it broke, and `#unknown` is the honest word for it.
682
+ * The type is `Record` over EVERY icon, not over `string`. A new icon added to
683
+ * `ICON` without a word here now fails to compile. Under the old loose type it
684
+ * fell through a `?? 'news'` default instead: a card whose outcome nobody had
685
+ * decided was indistinguishable from a card that is genuinely just news, and
686
+ * nothing anywhere went red. `news` is therefore written out for each icon
687
+ * that means it, never left to a fallback.
681
688
  */
682
689
  export const OUTCOME_TAG = {
683
690
  [ICON.red]: 'fail',
@@ -686,20 +693,16 @@ export const OUTCOME_TAG = {
686
693
  [ICON.unknown]: 'unknown',
687
694
  [ICON.ok]: 'ok',
688
695
  [ICON.landed]: 'ok',
689
- [ICON.approved]: 'ok'
696
+ [ICON.approved]: 'ok',
697
+ // Something happened; no verdict was passed on it.
698
+ [ICON.fresh]: 'news',
699
+ [ICON.taken]: 'news',
700
+ [ICON.discarded]: 'news',
701
+ [ICON.changes]: 'news',
702
+ [ICON.info]: 'news'
690
703
  };
691
- // Everything left over a task was opened, a PR opened, edits asked for, a
692
- // digest — is news: something happened, no verdict was passed.
693
- export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)] ?? 'news';
704
+ export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)];
694
705
  const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
695
- /**
696
- * The tag line for free-form HTML (`sendReport`). The tag is the owner's
697
- * FILTER, and it has nothing to do with the body's format: a daily report
698
- * stays free-form text, but stops being the one card with no tags. The key
699
- * used to hang off the tail as `<i><code>#key</code></i>` — that is the old
700
- * format, from before tags moved to the first line.
701
- */
702
- export const reportTags = (key) => `#report #${esc(slug(key))}`;
703
706
  /**
704
707
  * Renders an event into finished HTML text, cut to Telegram's limit.
705
708
  * Tags are the FIRST line, added before the cut (not after, as before):
package/dist/send.d.ts CHANGED
@@ -1,21 +1,3 @@
1
- import type { NotifyEvent, Project } from './events.ts';
1
+ import type { NotifyEvent } from './events.ts';
2
2
  export type SendResult = 'sent' | 'skipped' | 'failed';
3
3
  export declare const notify: (e: NotifyEvent) => Promise<SendResult>;
4
- /**
5
- * Готовый HTML во вкладку «Ops» проекта — ТОЛЬКО для дневных отчётов.
6
- *
7
- * Зачем исключение из правила «свободного текста в API нет». Отчёт — это не
8
- * событие: в нём плотная строка вроде
9
- * `🎮 1284 игр (🍎 412 iOS +3 · 🤖 890 Android +5) | 📈 +240 запусков`,
10
- * и разложить её в `label=value` можно только испортив. Но транспорт у отчёта
11
- * ТОТ ЖЕ: ретраи, 429, таймауты, curl-фолбэк, номер вкладки. Пока его копировали
12
- * в каждый скрипт, один и тот же баг с дублями на таймауте пришлось чинить
13
- * дважды — в пакете и в game-publisher (27.07.2026).
14
- *
15
- * Граница: формат событий по-прежнему задаёт только пакет, «своё» уведомление
16
- * о деплое или упавшей задаче написать нельзя. Здесь стандартизирован транспорт,
17
- * а не формат — и в этом весь смысл.
18
- *
19
- * Всегда беззвучно: отчёт читают утром, а не по звонку.
20
- */
21
- export declare const sendReport: (project: Project, html: string, key?: string) => Promise<SendResult>;
package/dist/send.js CHANGED
@@ -15,7 +15,7 @@
15
15
  import { execFileSync } from 'node:child_process';
16
16
  import { readFileSync } from 'node:fs';
17
17
  import { basename } from 'node:path';
18
- import { clampMessage, render, reportTags } from "./render.js";
18
+ import { render } from "./render.js";
19
19
  import { ROUTES, targets } from "./routes.js";
20
20
  const log = (msg) => {
21
21
  // stderr, не stdout — stdout зарезервирован под возможный машинный вывод CLI.
@@ -133,7 +133,7 @@ const sendOne = async (token, target, text) => {
133
133
  log('out of send attempts');
134
134
  return 'failed';
135
135
  };
136
- /** Общий хвост для `notify` и `sendReport`: токен, цели, последовательная отправка. */
136
+ /** The shared tail of `notify()`: token, targets, delivery one after another. */
137
137
  const deliver = async (where, text) => {
138
138
  const token = process.env.OPS_BOT_TOKEN?.trim();
139
139
  if (!token) {
@@ -285,34 +285,3 @@ export const notify = async (e) => {
285
285
  }
286
286
  return deliver(targets(e), render(e));
287
287
  };
288
- /**
289
- * Готовый HTML во вкладку «Ops» проекта — ТОЛЬКО для дневных отчётов.
290
- *
291
- * Зачем исключение из правила «свободного текста в API нет». Отчёт — это не
292
- * событие: в нём плотная строка вроде
293
- * `🎮 1284 игр (🍎 412 iOS +3 · 🤖 890 Android +5) | 📈 +240 запусков`,
294
- * и разложить её в `label=value` можно только испортив. Но транспорт у отчёта
295
- * ТОТ ЖЕ: ретраи, 429, таймауты, curl-фолбэк, номер вкладки. Пока его копировали
296
- * в каждый скрипт, один и тот же баг с дублями на таймауте пришлось чинить
297
- * дважды — в пакете и в game-publisher (27.07.2026).
298
- *
299
- * Граница: формат событий по-прежнему задаёт только пакет, «своё» уведомление
300
- * о деплое или упавшей задаче написать нельзя. Здесь стандартизирован транспорт,
301
- * а не формат — и в этом весь смысл.
302
- *
303
- * Всегда беззвучно: отчёт читают утром, а не по звонку.
304
- */
305
- export const sendReport = async (project, html, key) => {
306
- if (!Object.hasOwn(ROUTES, project)) {
307
- // Та же красная карточка, что у notify(): дверь свободного HTML — не
308
- // лазейка для молчаливой потери (этот класс уже жил неделями дважды).
309
- await reportLostProject(project, 'report');
310
- return 'skipped';
311
- }
312
- const forum = ROUTES[project];
313
- // Без проекта — как и render.ts: карточка уже лежит в форуме своего
314
- // проекта, дублировать его в теге незачем. Строка тегов ПЕРВАЯ и до обрезки,
315
- // как у любой другой карточки.
316
- const tags = reportTags(key ?? 'daily');
317
- return deliver([{ chat: forum.chat, thread: forum.ops, silent: true }], `${tags}\n${clampMessage(html, Math.max(64, 4000 - tags.length - 1))}`);
318
- };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",