@mikitasazan/notify 1.4.1 → 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/README.md CHANGED
@@ -85,36 +85,42 @@ notify report --project playhub --json < payload.json # весь объект
85
85
  | `pr` | событие пул-реквеста | `project`, `action`, `number`, `title` |
86
86
  | `issue` | событие задачи | `project`, `action`, `number`, `title` |
87
87
  | `incident` | приложение сломалось прямо сейчас | `project`, `title` |
88
- | `heartbeat_miss` | задача не отметилась вовремя | `project`, `job` |
89
- | `file` | файл-вложение с подписью-карточкой | `project`, `title`, `path` |
88
+ | `session` | рабочая сессия на маке в беде | `project`, `action` |
89
+ | `heartbeat_miss` | УСТАРЕЛ, молчание это `job --status silent` | `project`, `job` |
90
+
91
+ Отдельного вида «файл» нет: `--path` применим к ЛЮБОМУ событию, и тогда
92
+ карточка едет подписью к вложению (подпись у Telegram ограничена 1024 знаками,
93
+ а не 4000). Слово `file` осталось псевдонимом и собирает `report` с вложением —
94
+ старый отправитель не замолкает.
90
95
 
91
96
  Полные сигнатуры — `src/events.ts`.
92
97
 
93
- **Ключ задачи.** Последняя строка каждой карточки — `#проект/ключ` в `<code>`.
94
- По нему дневной разборщик сверяет «это 🔴 уже закрыто более поздней карточкой
98
+ **Ключ задачи.** Первая строка каждой карточки — два тега: `#тип #ключ`.
99
+ По ним дневной разборщик сверяет «это 🔴 уже закрыто более поздней карточкой
95
100
  той же задачи?» без сравнения человеческих формулировок. Явный `--key`
96
101
  побеждает; без него ключ выводится из заголовка и меняется вместе с ним —
97
102
  регулярный отправитель передаёт `--key` явно. В stdout CLI ключ не попадает.
98
103
 
99
104
  **Единственная дверь для свободного HTML** — `sendReport()` (`report --json` у
100
105
  аналитик): плотную строку дневного отчёта не разложить в `label=value` не
101
- испортив. Стандартизирован транспорт, формат — нет, и только здесь.
106
+ испортив. Стандартизирован транспорт, формат тела — нет, и только здесь.
107
+ Строку тегов `#report #ключ` пакет ставит и здесь: тег — фильтр владельца, к
108
+ формату тела он отношения не имеет. Без явного ключа берётся `daily`.
102
109
 
103
110
  **Неизвестный проект** не роняет вызвавший крон (код возврата 0), но больше и
104
111
  не исчезает молча: в mac-config Ops уходит красная карточка «notify: событие
105
112
  потеряно». До 18.08.2026 опечатка в `--project` терялась без следа неделями.
106
113
 
107
- `--action` у `pr`: `opened`, `ready_for_review`, `review_requested`, `approved`,
108
- `changes_requested`, `merged`, `closed`. У `issue`: `opened`, `assigned`,
109
- `closed`. Принимаются и сырые имена GitHub (`reopened`,
110
- `review_request_removed`). Неизвестное действие — ошибка разбора, а не молчаливая
111
- подмена: иначе «запрошены правки» приехали бы как «открыт».
112
-
113
- **Но из GitHub в «⚙️ Ops» уходит не всё, что пакет умеет нарисовать.**
114
- `ready_for_review` и `review_requested` рендерятся (их можно послать вручную из
115
- CLI), однако общий workflow `.github/workflows/ops-notify.yml` их отбрасывает:
116
- оба сообщают про PR, который уже объявлен открытым, и открытие одного PR давало
117
- три карточки подряд. Правило владельца от 27.07.2026 — одна новость, одна
114
+ `--action` у `pr`: `opened`, `approved`, `changes_requested`, `merged`,
115
+ `closed`. У `issue`: `opened`, `assigned`, `closed`. Сырые имена GitHub, которые
116
+ означают то же самое, сводятся к ним: `reopened`, `ready_for_review` и
117
+ `review_requested` — это всё `opened`. Неизвестное действие — ошибка разбора, а
118
+ не молчаливая подмена: иначе «запрошены правки» приехали бы как «открыт».
119
+
120
+ **Из GitHub в «⚙️ Ops» уходит не всё, что там происходит.** Общий workflow
121
+ `.github/workflows/ops-notify.yml` отбрасывает `ready_for_review` и
122
+ `review_requested` ещё на входе: оба сообщают про PR, который уже объявлен
123
+ открытым, и открытие одного PR давало три карточки подряд. Правило владельца от 27.07.2026 — одна новость, одна
118
124
  карточка: **открыт → вердикт ревью (👍 / 📝) → закрыт или смёржен**. Белый список
119
125
  живёт в общем workflow, а не в подписках проектов, потому что подписка обязана
120
126
  лежать в вызывающем репозитории и в четырёх проектах расходится сама собой;
package/dist/cli-flags.js CHANGED
@@ -10,8 +10,9 @@
10
10
  */
11
11
  export const KNOWN_FLAGS = new Set([
12
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',
13
+ 'id', 'opened', 'reason', 'workdir',
14
+ 'command', 'command-note', 'commit-body', 'commit-title', 'commit-url', 'detail',
15
+ 'expected', 'filename', 'item', 'job', 'key', 'last-seen', 'line', 'logs', 'note',
15
16
  'number', 'path', 'period', 'project', 'reviewer', 'stat', 'status',
16
17
  'target', 'title', 'url', 'via', 'workflow-name', 'workflow-url',
17
18
  // Флаги без значения. Живут здесь же, чтобы разбор и список не разошлись.
package/dist/cli.js CHANGED
@@ -106,16 +106,32 @@ 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 = {
115
129
  opened: 'opened',
116
130
  reopened: 'opened',
117
- ready_for_review: 'ready_for_review',
118
- review_requested: 'review_requested',
131
+ // A PR that is already announced is not announced again: both of these mean
132
+ // "this PR now wants eyes", which is what `opened` already says.
133
+ ready_for_review: 'opened',
134
+ review_requested: 'opened',
119
135
  approved: 'approved',
120
136
  changes_requested: 'changes_requested',
121
137
  merged: 'merged',
@@ -165,6 +181,9 @@ const jobStatus = () => {
165
181
  if (raw === 'disabled') {
166
182
  return 'disabled';
167
183
  }
184
+ if (raw === 'silent') {
185
+ return 'silent';
186
+ }
168
187
  return raw === 'ok' || raw === 'success' || raw === 'passed' || raw === '0' ? 'ok' : 'fail';
169
188
  };
170
189
  let event;
@@ -206,9 +225,14 @@ else {
206
225
  project: project(),
207
226
  job: one('job') ?? '(no name)',
208
227
  status: jobStatus(),
228
+ expected: one('expected'),
229
+ lastSeen: one('last-seen'),
209
230
  stats: pairs('stat'),
210
231
  items: items(),
211
232
  note: one('note'),
233
+ command: one('command'),
234
+ commandNote: one('command-note'),
235
+ logs: one('logs'),
212
236
  workflowUrl: one('workflow-url'),
213
237
  workflowName: one('workflow-name'),
214
238
  url: one('url')
@@ -268,6 +292,21 @@ else {
268
292
  url: one('url')
269
293
  };
270
294
  break;
295
+ case 'session':
296
+ event = {
297
+ type: 'session',
298
+ project: project(),
299
+ action: one('action') ?? 'in trouble',
300
+ id: one('id'),
301
+ workdir: one('workdir'),
302
+ reason: one('reason'),
303
+ opened: one('opened'),
304
+ command: one('command'),
305
+ commandNote: one('command-note'),
306
+ // Only two states here, so `disabled` must not leak in from jobStatus.
307
+ status: jobStatus() === 'ok' ? 'ok' : 'fail'
308
+ };
309
+ break;
271
310
  case 'incident':
272
311
  event = {
273
312
  type: 'incident',
@@ -289,21 +328,19 @@ else {
289
328
  note: one('note')
290
329
  };
291
330
  break;
292
- case 'file': {
293
- const path = one('path');
294
- if (!path) {
295
- parseErrors.push('--path: required for a file event');
296
- }
331
+ // `file` is no longer a kind of event — an attachment is a property any
332
+ // card may have. The word is kept as an alias so senders that still say
333
+ // `notify file` deliver a report card with the log attached, instead of
334
+ // falling into the unknown-type branch and going silent.
335
+ case 'file':
297
336
  event = {
298
- type: 'file',
337
+ type: 'report',
299
338
  project: project(),
300
339
  title: one('title') ?? '(no title)',
301
- path: path ?? '',
302
- filename: one('filename'),
303
- note: one('note')
340
+ period: one('note'),
341
+ lines: []
304
342
  };
305
343
  break;
306
- }
307
344
  default:
308
345
  // В parseErrors, а не просто в лог: иначе неизвестный тип уходил в
309
346
  // тишину — событие не собиралось, ошибок разбора не было, и CLI выходил
@@ -316,6 +353,12 @@ else {
316
353
  // key в самом объекте, отсутствие флага не должно его затирать.
317
354
  if (event) {
318
355
  event.key = one('key') ?? event.key;
356
+ // --path is applicable to any type too, for the same reason --key is.
357
+ event.path = one('path') ?? event.path;
358
+ event.filename = one('filename') ?? event.filename;
359
+ }
360
+ if (event?.filename && !event.path) {
361
+ parseErrors.push('--filename: given without --path, so there is no file to name');
319
362
  }
320
363
  // Ошибки разбора — до отправки: лучше внятно сказать, что не так с командой,
321
364
  // чем прислать сообщение с «true» вместо ссылки или 🔴 на успешном деплое.
package/dist/events.d.ts CHANGED
@@ -22,8 +22,18 @@ export type Project = 'playhub' | 'one-q' | 'arvent' | 'game-publisher' | 'vault
22
22
  * потоком по словам `sent|failed|skipped`) ключ не попадает никогда — новое
23
23
  * слово там ослепило бы сторожа.
24
24
  */
25
+ /**
26
+ * Общая часть любого события. `path` — локальный файл, который едет ВМЕСТЕ с
27
+ * карточкой: карточка становится подписью к вложению. Отдельного вида `file`
28
+ * нет с 25.08.2026 — прогон Arvent слал вердикт и лог двумя карточками про
29
+ * одну новость.
30
+ */
25
31
  type Keyed = {
26
32
  key?: string;
33
+ /** Локальный файл; карточка уедет как подпись к нему (лимит подписи 1024). */
34
+ path?: string;
35
+ /** Имя файла в чате; по умолчанию — имя из `path`. */
36
+ filename?: string;
27
37
  };
28
38
  /**
29
39
  * Позиция списка внутри сообщения: задача из дайджеста, упавшая проверка,
@@ -78,12 +88,56 @@ export type NotifyEvent = Keyed & (
78
88
  type: 'job';
79
89
  project: Project;
80
90
  job: string;
81
- /** `disabled` — задача выключена извне (например GitHub Actions кончил бесплатные минуты), не провалилась сама. */
82
- status: 'ok' | 'fail' | 'disabled';
83
- stats?: Array<[label: string, value: string | number]>;
91
+ /**
92
+ * `disabled` задача выключена извне (например GitHub Actions кончил
93
+ * бесплатные минуты), не провалилась сама.
94
+ *
95
+ * `silent` — задача не отчиталась в срок: она не упала, она вообще не
96
+ * подала признаков жизни. Это состояние ЗАДАЧИ, а не отдельный вид
97
+ * события — оно жило типом `heartbeat_miss`, и владелец справедливо
98
+ * спросил, почему задача по расписанию у него под двумя разными тегами.
99
+ * Хуже того: сторож молчания шлёт тот же машинный ключ, что и сама
100
+ * задача, так что красная карточка `#heartbeat #daily_import` не
101
+ * закрывалась зелёной `#job #daily_import` — разборщик ищет пару по
102
+ * ПОЛНОМУ тегу. Один поток на задачу это чинит.
103
+ */
104
+ status: 'ok' | 'fail' | 'disabled' | 'silent';
105
+ /** Как часто задача обязана отмечаться — для `silent` и для возврата из него. */
106
+ expected?: string;
107
+ /** Когда её видели в последний раз. */
108
+ lastSeen?: string;
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]>;
84
120
  /** Детали: что именно упало, замечания прогона; у `disabled` — список выключенных процессов (каждый со своей ссылкой). */
85
121
  items?: Item[];
86
122
  note?: string;
123
+ /**
124
+ * A command for him to run, rendered monospaced so Telegram makes it
125
+ * tap-to-copy. For the case where the card names something on this Mac
126
+ * that no URL can reach — a stopped local session, a latch file.
127
+ */
128
+ command?: string;
129
+ /**
130
+ * WHAT that command does. The owner, on a bare `rm` in a card: "я сейчас
131
+ * введу её и сделаю хуй пойми что, я ж не знаю, что делаю". A command he
132
+ * cannot read is one he cannot run, so it never travels alone.
133
+ */
134
+ commandNote?: string;
135
+ /**
136
+ * A local log path — monospaced, not a link, same as on an incident.
137
+ * It used to be glued onto the end of the reason sentence behind a
138
+ * colon, which is what made a red card read as one long run-on line.
139
+ */
140
+ logs?: string;
87
141
  workflowUrl?: string;
88
142
  /** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
89
143
  workflowName?: string;
@@ -101,7 +155,7 @@ export type NotifyEvent = Keyed & (
101
155
  title: string;
102
156
  period?: string;
103
157
  /** Пусто/не передано, когда используются `groups` — два вида отчёта не смешиваются в одном событии. */
104
- lines?: Array<[label: string, value: string | number]>;
158
+ lines?: Array<[label: string, value: string | number, group?: string]>;
105
159
  /**
106
160
  * Список позиций со ссылками — для дайджестов задач, где ценность в
107
161
  * самих названиях, а не в цифре. Рендерятся отдельным блоком после
@@ -153,7 +207,7 @@ export type NotifyEvent = Keyed & (
153
207
  | {
154
208
  type: 'pr';
155
209
  project: Project;
156
- action: 'opened' | 'ready_for_review' | 'review_requested' | 'approved' | 'changes_requested' | 'merged' | 'closed';
210
+ action: 'opened' | 'approved' | 'changes_requested' | 'merged' | 'closed';
157
211
  number: number;
158
212
  title: string;
159
213
  /** PR description — quoted on its own; the title is the `Title:` field above it. */
@@ -185,7 +239,47 @@ export type NotifyEvent = Keyed & (
185
239
  logs?: string;
186
240
  url?: string;
187
241
  }
188
- /** Задача не отметилась вовремя — сторож молчания (heartbeat). */
242
+ /**
243
+ * A working session on this Mac is in trouble — not a job, not a workflow.
244
+ * It went out as `job` at first and read wrong: `#job` promises something
245
+ * scheduled that ran and failed, and the owner rightly asked what a burning
246
+ * session was doing under that heading.
247
+ *
248
+ * What makes it its own type rather than an `incident`: a session has an
249
+ * identity nothing else here has — an id, a working directory, and the line
250
+ * he typed to start it, which is the ONLY thing that tells two of his open
251
+ * sessions apart.
252
+ */
253
+ | {
254
+ type: 'session';
255
+ project: Project;
256
+ /** What happened, as the second line reads it: `Session: burning the limit`. */
257
+ action: string;
258
+ /** The session's own id — the identifier field, first, as everywhere else. */
259
+ id?: string;
260
+ /** Working directory name, when several sessions opened with a similar line. */
261
+ workdir?: string;
262
+ /** One line of measurement: what the guard saw. */
263
+ reason?: string;
264
+ /**
265
+ * The line he opened the session with. Quoted, never a field: it is his
266
+ * own writing, it runs long, and a field would clip it to one short line
267
+ * — which is exactly how the first version of this card lost it.
268
+ */
269
+ opened?: string;
270
+ /** A command for him to run, monospaced so Telegram makes it copyable. */
271
+ command?: string;
272
+ /** WHAT that command does — see the note on `job.commandNote`. */
273
+ commandNote?: string;
274
+ /** `fail` red, `ok` green — a session that recovered is not an alarm. */
275
+ status?: 'fail' | 'ok';
276
+ }
277
+ /**
278
+ * УСТАРЕЛО с 1.4.2: используйте `job` со статусом `silent`. Тип остаётся,
279
+ * потому что сторож молчания живёт на сервере и до выкатки шлёт именно его —
280
+ * убрать значит потерять карточку молчания ровно тогда, когда она нужна.
281
+ * Новых вызовов не добавлять.
282
+ */
189
283
  | {
190
284
  type: 'heartbeat_miss';
191
285
  project: Project;
@@ -196,26 +290,47 @@ export type NotifyEvent = Keyed & (
196
290
  recovered?: boolean;
197
291
  /** Готовое предложение-причина; без него собирается из lastSeen/expected. */
198
292
  note?: string;
199
- }
200
- /**
201
- * Файл-вложение (sendDocument) с подписью-карточкой. Появился, когда
202
- * eval-отчёт Arvent слал файл голым curl мимо пакета: без ретраев файл
203
- * терялся в любую сетевую икоту, а chat_id и номер вкладки жили копией,
204
- * которая устаревает молча. Маршрут — тот же `ROUTES`, транспорт — с теми
205
- * же повторами. Подпись у Telegram ограничена 1024 символами — режется
206
- * тем же безопасным клампом.
207
- */
208
- | {
209
- type: 'file';
210
- project: Project;
211
- title: string;
212
- /** Путь к локальному файлу. */
213
- path: string;
214
- /** Имя файла в чате; по умолчанию — имя из `path`. */
215
- filename?: string;
216
- note?: string;
217
293
  });
218
294
  export type EventType = NotifyEvent['type'];
219
295
  /** Красное = со звуком. Всё остальное — тихо. (Отдельной темы «инциденты» больше нет — авария видна в ленте проекта.) */
296
+ /**
297
+ * Словарь значков. Два закона, и оба поставлены владельцем 25.08.2026:
298
+ *
299
+ * 1. ВНУТРИ одного тега у каждого слова свой значок. Раньше значков было
300
+ * ровно четыре на весь пакет, и `Issue: opened` с `Issue: assigned`
301
+ * выглядели одинаково, а у задачи три разных беды — fail, disabled,
302
+ * silent — были одним и тем же красным кругом.
303
+ * 2. МЕЖДУ тегами одинаковый смысл выглядит одинаково. `fail` — это 🔴 и в
304
+ * выкатке, и в CI, и в задаче; «появилось новое» — 🆕 и у задачи на доске,
305
+ * и у PR, и у файла.
306
+ *
307
+ * И третий, который держит первые два честными: у значка фиксированный звук.
308
+ * Не у события, не у статуса — у значка. Пока звук выводился отдельным
309
+ * правилом, `🔴 PR: changes_requested` приходила беззвучно.
310
+ */
311
+ export declare const ICON: {
312
+ readonly ok: "✅";
313
+ readonly red: "🔴";
314
+ readonly alarm: "🚨";
315
+ readonly off: "🚫";
316
+ readonly unknown: "❓";
317
+ readonly fresh: "🆕";
318
+ readonly taken: "🙋";
319
+ readonly landed: "🎉";
320
+ readonly discarded: "🗑️";
321
+ readonly approved: "👍";
322
+ readonly changes: "📝";
323
+ readonly info: "ℹ️";
324
+ };
325
+ /** The sound is a property of the icon, and of nothing else. */
326
+ export declare const LOUD: ReadonlySet<string>;
327
+ export declare const PR_ICON: Record<Extract<NotifyEvent, {
328
+ type: 'pr';
329
+ }>['action'], string>;
330
+ export declare const ISSUE_ICON: Record<Extract<NotifyEvent, {
331
+ type: 'issue';
332
+ }>['action'], string>;
333
+ /** Одно место, где решается значок карточки, — и рендер, и звук берут его отсюда. */
334
+ export declare const iconFor: (e: NotifyEvent) => string;
220
335
  export declare const severity: (e: NotifyEvent) => "info" | "error";
221
336
  export {};
package/dist/events.js CHANGED
@@ -10,19 +10,82 @@
10
10
  * Тогда старый вызывающий код и новый пакет совместимы в обе стороны.
11
11
  */
12
12
  /** Красное = со звуком. Всё остальное — тихо. (Отдельной темы «инциденты» больше нет — авария видна в ленте проекта.) */
13
- export const severity = (e) => {
14
- if (e.type === 'heartbeat_miss') {
15
- return e.recovered ? 'info' : 'error';
16
- }
17
- if (e.type === 'incident') {
18
- return 'error';
19
- }
20
- // `disabled` рисуется красным (`ICON.red` в render.ts) ровно как `fail` —
21
- // задача не работает, что бы ни было тому причиной. Молчаливая отправка
22
- // красной карточки без звука хуже отсутствия карточки: авария выглядит
23
- // аварией, но не будит (тот же довод, что уже был у `fail`).
24
- if ('status' in e && (e.status === 'fail' || e.status === 'disabled')) {
25
- return 'error';
13
+ /**
14
+ * Словарь значков. Два закона, и оба поставлены владельцем 25.08.2026:
15
+ *
16
+ * 1. ВНУТРИ одного тега у каждого слова свой значок. Раньше значков было
17
+ * ровно четыре на весь пакет, и `Issue: opened` с `Issue: assigned`
18
+ * выглядели одинаково, а у задачи три разных беды — fail, disabled,
19
+ * silent — были одним и тем же красным кругом.
20
+ * 2. МЕЖДУ тегами одинаковый смысл выглядит одинаково. `fail` — это 🔴 и в
21
+ * выкатке, и в CI, и в задаче; «появилось новое» 🆕 и у задачи на доске,
22
+ * и у PR, и у файла.
23
+ *
24
+ * И третий, который держит первые два честными: у значка фиксированный звук.
25
+ * Не у события, не у статуса — у значка. Пока звук выводился отдельным
26
+ * правилом, `🔴 PR: changes_requested` приходила беззвучно.
27
+ */
28
+ export const ICON = {
29
+ ok: '✅', // passed, closed, done
30
+ red: '🔴', // broken
31
+ alarm: '🚨', // burning right now
32
+ off: '🚫', // switched off — it will not run until someone turns it back on
33
+ unknown: '❓', // did not report: alive or dead is unknown
34
+ fresh: '🆕', // something new appeared
35
+ taken: '🙋', // someone took it
36
+ landed: '🎉', // merged — the work is in
37
+ discarded: '🗑️', // closed without reaching the result
38
+ approved: '👍', // a human approved it
39
+ changes: '📝', // a human wants edits — not a failure, and not loud
40
+ info: 'ℹ️' // a summary, for information
41
+ };
42
+ /** The sound is a property of the icon, and of nothing else. */
43
+ export const LOUD = new Set([ICON.red, ICON.alarm, ICON.off, ICON.unknown]);
44
+ export const PR_ICON = {
45
+ opened: ICON.fresh,
46
+ approved: ICON.approved,
47
+ changes_requested: ICON.changes,
48
+ merged: ICON.landed,
49
+ closed: ICON.discarded
50
+ };
51
+ export const ISSUE_ICON = {
52
+ opened: ICON.fresh,
53
+ assigned: ICON.taken,
54
+ closed: ICON.ok
55
+ };
56
+ const JOB_ICON = {
57
+ ok: ICON.ok,
58
+ fail: ICON.red,
59
+ disabled: ICON.off,
60
+ silent: ICON.unknown
61
+ };
62
+ /** Одно место, где решается значок карточки, — и рендер, и звук берут его отсюда. */
63
+ export const iconFor = (e) => {
64
+ switch (e.type) {
65
+ case 'deploy':
66
+ case 'ci':
67
+ return e.status === 'ok' ? ICON.ok : ICON.red;
68
+ case 'job':
69
+ return JOB_ICON[e.status];
70
+ case 'session':
71
+ return e.status === 'ok' ? ICON.ok : ICON.alarm;
72
+ case 'incident':
73
+ return ICON.alarm;
74
+ case 'heartbeat_miss':
75
+ return e.recovered ? ICON.ok : ICON.unknown;
76
+ case 'pr':
77
+ return PR_ICON[e.action];
78
+ case 'issue':
79
+ return ISSUE_ICON[e.action];
80
+ case 'report':
81
+ return ICON.info;
26
82
  }
27
- return 'info';
83
+ };
84
+ export const severity = (e) => {
85
+ // ONE law: the icon decides the sound. There is no second list of "which
86
+ // events are bad" to keep in sync with the icons — keeping two lists is how
87
+ // `🔴 PR: changes_requested` ended up arriving MUTED, the only red card in
88
+ // the package that did not ring, because severity() looked at `status` and a
89
+ // pull request has an `action`.
90
+ return LOUD.has(iconFor(e)) ? 'error' : 'info';
28
91
  };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type { EventType, NotifyEvent, Project } from './events.ts';
2
2
  export { severity } from './events.ts';
3
3
  export { notify, sendReport } from './send.ts';
4
+ export { render } from './render.ts';
4
5
  export type { SendResult } from './send.ts';
package/dist/index.js CHANGED
@@ -1,2 +1,6 @@
1
1
  export { severity } from "./events.js";
2
2
  export { notify, sendReport } from "./send.js";
3
+ // `render` наружу — чтобы отправитель мог положить на диск РОВНО ту карточку,
4
+ // которая уехала, а не свою вторую версию текста. Сборка копии вручную уже
5
+ // расходилась с отправленным.
6
+ export { render } from "./render.js";
package/dist/render.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * строка разделяет БЛОКИ ПО СМЫСЛУ (шапка / суть / действия), не механически
20
20
  * после каждой строки.
21
21
  */
22
- import type { NotifyEvent } from './events.ts';
22
+ import { type NotifyEvent } from './events.ts';
23
23
  /** Экранируется ВСЁ, что пришло снаружи — теги ставит только шаблон. */
24
24
  export declare const esc: (v: unknown) => string;
25
25
  /**
@@ -36,6 +36,7 @@ export declare const esc: (v: unknown) => string;
36
36
  * повторяем — сообщение исчезало совсем.
37
37
  */
38
38
  export declare const clampMessage: (text: string, limit?: number) => string;
39
+ export declare const slug: (raw: string) => string;
39
40
  /**
40
41
  * Экземпляр-тег: что именно это конкретное событие (ветка, окружение,
41
42
  * задача, номер) — по нему разборщик сверяет 🔴 с более поздней зелёной
@@ -45,6 +46,14 @@ export declare const clampMessage: (text: string, limit?: number) => string;
45
46
  * заголовок и есть единственное стабильное поле).
46
47
  */
47
48
  export declare const eventKey: (e: NotifyEvent) => string;
49
+ /**
50
+ * Строка тегов для свободного HTML (`sendReport`). Тег — это ФИЛЬТР владельца,
51
+ * и к формату тела он отношения не имеет: дневной отчёт остаётся свободным
52
+ * текстом, но перестаёт быть единственной карточкой без тегов. Раньше ключ
53
+ * висел хвостом в `<i><code>#ключ</code></i>` — это старый формат, до того как
54
+ * теги переехали первой строкой.
55
+ */
56
+ export declare const reportTags: (key: string) => string;
48
57
  /**
49
58
  * Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
50
59
  * Теги — ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
package/dist/render.js CHANGED
@@ -1,5 +1,36 @@
1
+ /**
2
+ * Один рендерер на тип события, все по одному каркасу — утверждён
3
+ * владельцем 20.08.2026 после ~15 живых раундов в тестовом форуме:
4
+ *
5
+ * #тип #экземпляр
6
+ * значок <b>Тип:</b> действие
7
+ *
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
+ * после каждой строки.
21
+ */
22
+ import { ICON, LOUD, iconFor } from "./events.js";
1
23
  /** Первая буква — заглавная, остальное как есть (ga4/GitHub остаются собой). */
2
- const cap = (s) => (s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s);
24
+ /**
25
+ * Ярлык с большой буквы — но НЕ у имени, которое пишется со строчной нарочно:
26
+ * `iOS` превращалось в `IOS`. Признак — вторая буква заглавная.
27
+ */
28
+ const cap = (s) => {
29
+ if (s.length === 0 || /^[a-z][A-Z]/.test(s)) {
30
+ return s;
31
+ }
32
+ return s.charAt(0).toUpperCase() + s.slice(1);
33
+ };
3
34
  /** Экранируется ВСЁ, что пришло снаружи — теги ставит только шаблон. */
4
35
  export const esc = (v) => String(v ?? '')
5
36
  .replace(/&/g, '&amp;')
@@ -118,6 +149,23 @@ const fieldLink = (label, url, text) => {
118
149
  const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'open')}</a>` : null;
119
150
  /** Моноширинное поле — путь/команда для копирования, не ссылка. */
120
151
  const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
152
+ /**
153
+ * Строка, которая просит что-то ОТ ВЛАДЕЛЬЦА, а не сообщает факт. Она уже
154
+ * стояла последней и через пустую строку, и всё равно читалась как рядовое
155
+ * поле среди пяти других. Маркер `▶` — единственное отличие: заголовок группы
156
+ * здесь был бы третьей строкой разметки на карточку из шести (25.08.2026, два
157
+ * ревью против группировки), а маркер не тратит ни одной.
158
+ */
159
+ /**
160
+ * Действие: что сделать и чем. Без объяснения команда НЕ печатается вовсе —
161
+ * владелец на голую `rm` в карточке: «я ж не знаю, что делаю». Молча уронить
162
+ * строку лучше, чем показать ему команду, которую он не может прочитать;
163
+ * отправителя при этом ловит тест каталога, а не тишина в чате.
164
+ */
165
+ const fieldRun = (value, why) => {
166
+ const explain = field('To do', why);
167
+ return value && explain !== null ? [explain, `▶ <code>${esc(value)}</code>`] : [];
168
+ };
121
169
  /** Заголовок группы: курсив + подчёркивание, без жирности, без двоеточия. */
122
170
  const group = (name) => `<i><u>${esc(cap(name))}</u></i>`;
123
171
  /** Позиция внутри группы: `<b>label:</b> <a>text</a>` — либо простая маркированная/нумерованная строка без label. */
@@ -140,7 +188,36 @@ const note = (text) => {
140
188
  const body = esc(text);
141
189
  return body.length > EXPAND_AT ? `<blockquote expandable>${body}</blockquote>` : `<blockquote>${body}</blockquote>`;
142
190
  };
143
- const join = (parts) => parts.filter((p) => p !== null).join('\n');
191
+ /**
192
+ * Цитата с подписью. Голая цитата читается как продолжение поля над ней:
193
+ * владелец спросил про строку, которой открыта сессия, «что значит этот текст,
194
+ * откуда он берётся» — и был прав, в карточке это нигде не сказано. Подпись
195
+ * стоит отдельной строкой, потому что сам текст в поле не помещается: поле
196
+ * держит одну строку и обрезает.
197
+ */
198
+ const quoted = (label, text) => text ? `<b>${esc(cap(label))}</b>\n${note(text)}` : null;
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
+ };
144
221
  /** Плоский список позиций (без ярлыков) — job/report без групп. */
145
222
  const bullets = (items, numbered) => (items ?? []).map((it, i) => groupItem(it, i, numbered));
146
223
  /** Именованная группа целиком: заголовок + позиции, разделены строкой пустоты внутри вызова через join. */
@@ -163,19 +240,94 @@ const renderGroup = (g) => [
163
240
  */
164
241
  const titleField = (title) => field('Title', title);
165
242
  const bodyQuote = (body) => body ? note(body) : null;
166
- // Значок = статус сообщения, не тип события. Ровно четыре на весь пакет —
167
- // закреплённая легенда в форумах обещает это владельцу как факт, не как
168
- // приближение. 🔴 сломалось, 🚨 инцидент, ✅ прошло, ℹ️ к сведению.
169
- const ICON = { red: '🔴', alarm: '🚨', ok: '✅', info: 'ℹ️' };
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
+ };
314
+ // Значок и его закон живут в events.ts: от него зависит и звук.
170
315
  /** Строка 2: значок вне жирного, `<b>Тип:</b> действие` — то же поле, не особый случай. */
171
316
  // `action` объявлен строкой, но приходит и из `--json`, и из прямых вызовов на
172
317
  // JS, где типов нет. Пустое или отсутствующее значение давало строку `ℹ️ null`
173
318
  // прямо во второй строке карточки. Пустая строка честнее: поле просто исчезает.
174
- const typeLine = (icon, type, action) => {
319
+ // A link belongs on the NAME of the thing it opens, never on a separate row
320
+ // whose only text is the verb `open`. The owner read `Details: open` under a
321
+ // report and asked what "open" was — the answer is the report itself, which was
322
+ // sitting three lines above as dead text. So line 2 takes an optional URL and
323
+ // the action text becomes the link: `Report: <a>Analytics for 12.08</a>`.
324
+ const typeLine = (icon, type, action, url) => {
175
325
  // `field` возвращает null на пустом значении, а интерполяция null в шаблон
176
326
  // печатает слово «null». Так вторая строка карточки становилась `ℹ️ null` —
177
327
  // достижимо через `--json` и прямой вызов на JS, где типов нет.
178
- const line = field(type, action);
328
+ // `action || 'open'` in the linked case: an empty title must not swallow the
329
+ // link, which would be the one thing the card cannot afford to lose.
330
+ const line = url ? fieldLink(type, url, action || 'open') : field(type, action);
179
331
  return line === null ? `${icon} <b>${esc(cap(type))}</b>` : `${icon} ${line}`;
180
332
  };
181
333
  // `workflowUrl ?? url`: половина отправителей шлёт ссылку на прогон под именем
@@ -183,23 +335,40 @@ const typeLine = (icon, type, action) => {
183
335
  // только `workflowUrl`, поэтому красная карточка приходила БЕЗ ЕДИНОЙ ССЫЛКИ
184
336
  // на логи. Отвергать `--url` было бы честнее по имени и хуже по делу: намерение
185
337
  // однозначно, а карточка без ссылки бесполезна ровно тогда, когда нужна.
338
+ /**
339
+ * What to call the thing that ran. The workflow's own name first — it is the
340
+ * only text here that identifies THIS run. Then the caller's own word for the
341
+ * mechanism (`manual, from the Mac`). Last resort `the run`, and only when a
342
+ * link exists: losing the link to the logs on a red card is the one loss this
343
+ * format cannot afford, and a row that says nothing is still better than a
344
+ * card with nowhere to click. No live sender reaches that last resort — the
345
+ * GitHub Action always fills the workflow name, and the hand-run scripts send
346
+ * no run link at all.
347
+ */
348
+ const mechanism = (workflowName, via, runUrl) => workflowName ?? via ?? (runUrl ? 'the run' : undefined);
349
+ // The name of what ran sits WITH the type line, not eight lines below it.
350
+ // `Deploy: fail` and `by what means it ran` answer one question, and the owner
351
+ // read the two rows as unrelated things. It used to be one fact split in two:
352
+ // `Via: GitHub Actions` in the middle of the card and a trailing
353
+ // `Workflow: <run>` in the actions block. On one-q that trailing row rendered
354
+ // `Workflow: Deploy` — the link text repeating the word on line 2 and naming
355
+ // nothing.
356
+ //
357
+ // The link text is the workflow's OWN name, never the platform: `GitHub
358
+ // Actions` is identical on every card in every repository, so clicking it told
359
+ // the owner nothing about where he was going. `manual, from the Mac` stays
360
+ // unlinked, because a hand deploy has no run to open.
186
361
  const renderDeploy = (e) => {
187
- const icon = e.status === 'ok' ? ICON.ok : ICON.red;
362
+ const icon = iconFor(e);
363
+ const runUrl = e.workflowUrl ?? e.url;
188
364
  return join([
189
365
  typeLine(icon, 'Deploy', e.status),
190
- '',
191
- fieldLink('Commit', e.commitUrl, e.commit),
192
- titleField(e.commitTitle),
193
- bodyQuote(e.commitBody),
194
- field('Via', e.via),
195
- field('Target', e.target),
196
- field('Reason', e.note),
197
- e.workflowUrl ?? e.url ? '' : null,
198
- fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
366
+ fieldLink('Via', runUrl, mechanism(e.workflowName, e.via, runUrl)),
367
+ ...twoBlocks([field('Target', e.target), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
199
368
  ]);
200
369
  };
201
370
  const renderJob = (e) => {
202
- const icon = e.status === 'fail' || e.status === 'disabled' ? ICON.red : ICON.ok;
371
+ const icon = iconFor(e);
203
372
  const hasItems = (e.items ?? []).length > 0;
204
373
  const disabledList = hasItems && e.status === 'disabled';
205
374
  return join([
@@ -210,77 +379,79 @@ const renderJob = (e) => {
210
379
  // repeating the label of the line right above it reads as a mistake.
211
380
  // Until now the name was dropped entirely — every caller passed it and the
212
381
  // owner only ever saw it as the small grey instance tag.
213
- field('Task', e.job),
382
+ // The run link rides on the task's own name. It used to sit at the bottom
383
+ // as `Workflow: open` — every job caller passes a URL and none passes a
384
+ // workflow name, so that row was the bare verb the owner objected to.
385
+ fieldLink('Task', e.workflowUrl ?? e.url, e.job),
214
386
  field('Reason', e.note),
215
- ...(e.stats ?? []).map(([label, value]) => field(label, value)),
387
+ field('Expected', e.expected),
388
+ // `Last run` when the task is alive, `Last seen` when it is not: the same
389
+ // timestamp answers two different questions.
390
+ field(e.status === 'silent' ? 'Last seen' : 'Last run', e.lastSeen),
391
+ ...labelled(e.stats),
216
392
  hasItems ? '' : null,
217
393
  // Heading ONLY for `disabled`. It used to print for any job carrying a
218
394
  // list, so playhub's daily card of newly published games was headed
219
395
  // "Disabled workflows".
220
396
  disabledList ? group('Disabled workflows') : null,
221
397
  ...(hasItems ? bullets(e.items, disabledList) : []),
222
- e.workflowUrl ?? e.url ? '' : null,
223
- fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
398
+ e.command || e.logs ? '' : null,
399
+ fieldCode('Log', e.logs),
400
+ ...fieldRun(e.command, e.commandNote),
401
+ // Kept only when the caller actually names the workflow — a named row is a
402
+ // second, different destination; an unnamed one repeats the Task link.
403
+ e.workflowName && (e.workflowUrl ?? e.url) ? '' : null,
404
+ e.workflowName ? fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName) : null
224
405
  ]);
225
406
  };
226
407
  const renderReport = (e) => {
227
408
  if (e.groups && e.groups.length > 0) {
228
409
  const body = e.groups.flatMap((g, i) => (i === 0 ? renderGroup(g) : ['', ...renderGroup(g)]));
410
+ // `lines` и `groups` вместе, а не «или»: раньше ветка с группами печатала
411
+ // ТОЛЬКО группы, и цифры отчёта молча исчезали. Поймано 25.08.2026 при
412
+ // переводе утреннего отчёта PlayHub на типизированное событие.
413
+ const numbers = labelled(e.lines);
229
414
  return join([
230
- typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title),
415
+ typeLine(iconFor(e), 'Report', e.title, e.url),
416
+ // Период стоит ВПЛОТНУЮ к названию, без пустой строки, по тому же
417
+ // закону, что `Via` у выкатки и `Check` у проверки: строка, которая
418
+ // уточняет вторую строку, живёт рядом с ней, а не в блоке фактов.
419
+ // Владелец: «период пошёл не туда, он же должен быть рядом с датой».
420
+ field('Period', e.period),
231
421
  '',
232
- ...body,
233
- e.url ? '' : null,
234
- fieldAction('Details', e.url, undefined)
422
+ ...numbers,
423
+ body.length > 0 ? '' : null,
424
+ ...body
235
425
  ]);
236
426
  }
237
427
  const items = bullets(e.items, false);
238
428
  return join([
239
- typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title),
429
+ // Both analytics jobs send a link to the day's snapshot in docs/. It used to
430
+ // hang off a trailing `Details: open` row; now it is the report's own name.
431
+ typeLine(iconFor(e), 'Report', e.title, e.url),
432
+ // Вплотную к названию — см. соседнюю ветку.
433
+ field('Period', e.period),
240
434
  '',
241
- ...(e.lines ?? []).map(([label, value]) => field(label, value)),
435
+ ...labelled(e.lines),
242
436
  items.length > 0 ? '' : null,
243
- ...items,
244
- // Обе аналитики шлют сюда ссылку на снимок дня в docs/. Рендер её не читал,
245
- // и дневной отчёт приходил без единственного способа посмотреть подробности.
246
- e.url ? '' : null,
247
- fieldAction('Details', e.url, undefined)
437
+ ...items
248
438
  ]);
249
439
  };
440
+ // Same law as the deploy card, one row up: what ran is named beside the type
441
+ // line and carries the link to its run. The label is `Check` and not `Via`
442
+ // because here the name answers WHICH gate spoke — `nightly`, `Quality` —
443
+ // while on a deploy it answers by what means the code was shipped.
250
444
  const renderCi = (e) => {
251
- const icon = e.status === 'ok' ? ICON.ok : ICON.red;
445
+ const icon = iconFor(e);
446
+ const runUrl = e.workflowUrl ?? e.url;
252
447
  return join([
253
448
  typeLine(icon, 'CI', e.status),
254
- '',
255
- fieldLink('Commit', e.commitUrl, e.commit),
256
- titleField(e.commitTitle),
257
- bodyQuote(e.commitBody),
258
- field('Actor', e.actor),
259
- field('Reason', e.note),
260
- e.workflowUrl ?? e.url ? '' : null,
261
- fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
449
+ fieldLink('Check', runUrl, mechanism(e.workflowName, undefined, runUrl)),
450
+ ...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
262
451
  ]);
263
452
  };
264
- // PR/Issue: значок теперь по статусу (четыре на пакет), не по действию —
265
- // `merged`/`approved` = успех, `changes_requested` = требует внимания,
266
- // остальное = к сведению. Слово действия само по себе уже говорит, что
267
- // произошло (`opened`, `ready_for_review` и т.д.), значок дублировать не должен.
268
- const PR_ICON = {
269
- opened: ICON.info,
270
- ready_for_review: ICON.info,
271
- review_requested: ICON.info,
272
- approved: ICON.ok,
273
- changes_requested: ICON.red,
274
- merged: ICON.ok,
275
- closed: ICON.info
276
- };
277
- const ISSUE_ICON = {
278
- opened: ICON.info,
279
- assigned: ICON.info,
280
- closed: ICON.ok
281
- };
282
453
  const renderPr = (e) => join([
283
- typeLine(PR_ICON[e.action], 'PR', e.action),
454
+ typeLine(iconFor(e), 'PR', e.action),
284
455
  '',
285
456
  // Идентификатор первым, заголовок под ним: так вещь читается «#118, вот
286
457
  // такая», а не «вот такая, кстати #118» — и так её пишет сам GitHub.
@@ -294,7 +465,7 @@ const renderPr = (e) => join([
294
465
  field('Reviewer', e.reviewer)
295
466
  ]);
296
467
  const renderIssue = (e) => join([
297
- typeLine(ISSUE_ICON[e.action], 'Issue', e.action),
468
+ typeLine(iconFor(e), 'Issue', e.action),
298
469
  '',
299
470
  fieldLink('Number', e.url, `#${e.number}`),
300
471
  titleField(e.title),
@@ -303,7 +474,7 @@ const renderIssue = (e) => join([
303
474
  field('Assignee', e.assignee)
304
475
  ]);
305
476
  const renderIncident = (e) => join([
306
- typeLine(ICON.alarm, 'Incident', 'open'),
477
+ typeLine(iconFor(e), 'Incident', 'open'),
307
478
  '',
308
479
  // `detail` is a diagnosis of several lines (vault greps three of them plus a
309
480
  // log path). It used to go through `field`, which keeps only the first line,
@@ -311,18 +482,33 @@ const renderIncident = (e) => join([
311
482
  // commit now: short label, full text quoted under it.
312
483
  // Ярлык `Title`, а не `Reason`: у аварии заголовок — такой же заголовок,
313
484
  // как у коммита и задачи, и называться в одной карточке он должен так же.
314
- titleField(e.title),
485
+ // Same rule as the report: the link rides on the incident's own title
486
+ // rather than on a trailing row whose only text is the word `open`.
487
+ fieldLink('Title', e.url, e.title),
315
488
  e.detail && e.detail !== e.title ? note(e.detail) : null,
316
- e.logs || e.url ? '' : null,
317
- fieldCode('Logs', e.logs),
318
- fieldAction('Workflow', e.url, undefined)
489
+ e.logs ? '' : null,
490
+ fieldCode('Logs', e.logs)
319
491
  ]);
320
492
  // Раньше всё это склеивалось в одну строку `Reason:` через тире: «имя — no
321
493
  // reports — expected X, last seen Y». Каждая другая карточка кладёт факт на
322
494
  // свою строку с ярлыком, и владелец справедливо спросил, зачем тут отдельный
323
495
  // формат. Отдельного формата больше нет.
496
+ // A session in trouble. Same law as every other card: identifier first, then
497
+ // the facts as fields, then his own words as a quote — never as a field, which
498
+ // keeps one line and clipped the name of the very session the card is about.
499
+ const renderSession = (e) => join([
500
+ typeLine(iconFor(e), 'Session', e.action),
501
+ '',
502
+ field('Id', e.id),
503
+ field('Project', e.workdir),
504
+ field('Reason', e.reason),
505
+ e.opened ? '' : null,
506
+ quoted('Opened with', e.opened),
507
+ e.command ? '' : null,
508
+ ...fieldRun(e.command, e.commandNote)
509
+ ]);
324
510
  const renderHeartbeatMiss = (e) => {
325
- const icon = e.recovered ? ICON.ok : ICON.red;
511
+ const icon = iconFor(e);
326
512
  const action = e.recovered ? 'ok' : 'miss';
327
513
  return join([
328
514
  typeLine(icon, 'Heartbeat', action),
@@ -333,16 +519,6 @@ const renderHeartbeatMiss = (e) => {
333
519
  field(e.recovered ? 'Last run' : 'Last seen', e.lastSeen)
334
520
  ]);
335
521
  };
336
- // Подпись файла — та же карточка, но лимит Telegram у caption свой: 1024.
337
- const renderFile = (e) => join([
338
- typeLine(ICON.info, 'File', 'new'),
339
- '',
340
- // Раньше здесь стояло `field('Title', e.note ?? e.title)`: подпись файла
341
- // приходила под ярлыком заголовка, а сам заголовок из карточки исчезал.
342
- // Один ярлык — один смысл: Title это title, Reason это note.
343
- field('Title', e.title),
344
- field('Reason', e.note)
345
- ]);
346
522
  const RENDERERS = {
347
523
  deploy: renderDeploy,
348
524
  job: renderJob,
@@ -351,8 +527,8 @@ const RENDERERS = {
351
527
  pr: renderPr,
352
528
  issue: renderIssue,
353
529
  incident: renderIncident,
354
- heartbeat_miss: renderHeartbeatMiss,
355
- file: renderFile
530
+ session: renderSession,
531
+ heartbeat_miss: renderHeartbeatMiss
356
532
  };
357
533
  // Тег наверху карточки И машинный ключ разборщика — ОДНО И ТО ЖЕ значение
358
534
  // (решение владельца 20.08.2026): раньше это были два разных представления
@@ -361,7 +537,7 @@ const RENDERERS = {
361
537
  // разрывает Telegram-хэштег на середине слова (`#mac-config` линкуется
362
538
  // только как `#mac`), а тег ДОЛЖЕН быть кликабельным — это и есть фильтр
363
539
  // «показать всю историю этого экземпляра», которым владелец пользуется вживую.
364
- const slug = (raw) => raw
540
+ export const slug = (raw) => raw
365
541
  .toLowerCase()
366
542
  .replace(/[^\p{L}\p{N}]+/gu, '_')
367
543
  .replace(/^_+|_+$/g, '')
@@ -377,8 +553,8 @@ const TYPE_TAG = {
377
553
  pr: 'pr',
378
554
  issue: 'issue',
379
555
  incident: 'incident',
380
- heartbeat_miss: 'heartbeat',
381
- file: 'file'
556
+ session: 'session',
557
+ heartbeat_miss: 'heartbeat'
382
558
  };
383
559
  /**
384
560
  * Экземпляр-тег: что именно это конкретное событие (ветка, окружение,
@@ -400,8 +576,11 @@ export const eventKey = (e) => {
400
576
  return slug(e.job);
401
577
  case 'report':
402
578
  case 'incident':
403
- case 'file':
404
579
  return slug(e.title);
580
+ // NOT the session id: an id is unique per session, so the tag would be
581
+ // new every time and nothing could ever be paired with anything.
582
+ case 'session':
583
+ return slug(e.action);
405
584
  case 'pr':
406
585
  return `p${e.number}`;
407
586
  case 'issue':
@@ -410,7 +589,36 @@ export const eventKey = (e) => {
410
589
  };
411
590
  return e.key ? slug(e.key) : fallback();
412
591
  };
413
- 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)}`;
614
+ /**
615
+ * Строка тегов для свободного HTML (`sendReport`). Тег — это ФИЛЬТР владельца,
616
+ * и к формату тела он отношения не имеет: дневной отчёт остаётся свободным
617
+ * текстом, но перестаёт быть единственной карточкой без тегов. Раньше ключ
618
+ * висел хвостом в `<i><code>#ключ</code></i>` — это старый формат, до того как
619
+ * теги переехали первой строкой.
620
+ */
621
+ export const reportTags = (key) => `#report #${esc(slug(key))}`;
414
622
  /**
415
623
  * Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
416
624
  * Теги — ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
@@ -430,6 +638,7 @@ export const render = (e) => {
430
638
  // clampMessage может выйти за переданный limit на хвост закрывающих тегов и
431
639
  // многоточие — минус 40 оставляет ему этот запас. У сообщений свой запас уже
432
640
  // есть (4000 против 4096 у Telegram), у caption лимит 1024 настоящий.
433
- const budget = Math.max(64, e.type === 'file' ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
641
+ // Карточка с вложением это подпись, поэтому бюджет выбирается по `path`.
642
+ const budget = Math.max(64, e.path ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
434
643
  return `${tags}\n${clampMessage(renderer(e), budget)}`;
435
644
  };
package/dist/routes.js CHANGED
@@ -34,7 +34,7 @@ export const targets = (e) => {
34
34
  // Возвращаем пустой список, а не падаем: уведомление не имеет права уронить
35
35
  // вызвавший его деплой или крон (в bash с `set -e` падение было бы фатальным).
36
36
  if (!forum) {
37
- console.error(`[notify] неизвестный проект «${e.project}»известны: ${Object.keys(ROUTES).join(', ')}`);
37
+ console.error(`[notify] unknown project "${e.project}"known: ${Object.keys(ROUTES).join(', ')}`);
38
38
  return [];
39
39
  }
40
40
  return [{ chat: forum.chat, thread: forum.ops, silent: severity(e) === 'info' }];
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 } from "./render.js";
18
+ import { clampMessage, render, reportTags } from "./render.js";
19
19
  import { ROUTES, targets } from "./routes.js";
20
20
  const log = (msg) => {
21
21
  // stderr, не stdout — stdout зарезервирован под возможный машинный вывод CLI.
@@ -279,7 +279,8 @@ export const notify = async (e) => {
279
279
  await reportLostProject(e.project, String(e.type));
280
280
  return 'skipped';
281
281
  }
282
- if (e.type === 'file') {
282
+ // A card with a file becomes the caption of that file — one card, not two.
283
+ if (e.path) {
283
284
  return sendFile(e);
284
285
  }
285
286
  return deliver(targets(e), render(e));
@@ -310,7 +311,8 @@ export const sendReport = async (project, html, key) => {
310
311
  }
311
312
  const forum = ROUTES[project];
312
313
  // Без проекта — как и render.ts: карточка уже лежит в форуме своего
313
- // проекта, дублировать его в теге незачем.
314
- const tag = key ? `\n<i><code>#${key}</code></i>` : '';
315
- return deliver([{ chat: forum.chat, thread: forum.ops, silent: true }], clampMessage(html) + tag);
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))}`);
316
318
  };
package/dist/setup.js CHANGED
@@ -23,21 +23,21 @@ const createTopic = async (token, chat, name, color) => {
23
23
  });
24
24
  const body = (await res.json());
25
25
  if (!body.ok || !body.result) {
26
- log(`не удалось создать «${name}»: ${body.description ?? `HTTP ${res.status}`}`);
26
+ log(`could not create "${name}": ${body.description ?? `HTTP ${res.status}`}`);
27
27
  return null;
28
28
  }
29
29
  return body.result.message_thread_id;
30
30
  }
31
31
  catch (err) {
32
32
  // Сеть/таймаут/нечитаемый ответ — не роняем CLI (его контракт: всегда exit 0).
33
- log(`не удалось создать «${name}»: ${err instanceof Error ? err.message : String(err)}`);
33
+ log(`could not create "${name}": ${err instanceof Error ? err.message : String(err)}`);
34
34
  return null;
35
35
  }
36
36
  };
37
37
  export const setupTopic = async (chatId, projectKey) => {
38
38
  const token = process.env.OPS_BOT_TOKEN?.trim();
39
39
  if (!token) {
40
- log('нет OPS_BOT_TOKEN — не могу создать вкладки');
40
+ log('no OPS_BOT_TOKEN — cannot create the tabs');
41
41
  return;
42
42
  }
43
43
  const ops = await createTopic(token, chatId, '⚙️ Ops', 9367192);
@@ -45,16 +45,16 @@ export const setupTopic = async (chatId, projectKey) => {
45
45
  // Частичный успех: если создался только Ops — печатаем его, иначе повторный
46
46
  // запуск создал бы ДРУГУЮ тему Ops, а старый id потерялся бы.
47
47
  if (ops === null) {
48
- log('Ops не созданпроверь: бот админ группы с правом «Управление темами», темы включены?');
48
+ log('Ops was not created check: is the bot a group admin with "Manage topics", and are topics on?');
49
49
  return;
50
50
  }
51
51
  if (dev === null) {
52
- log(`Ops создан (id=${ops}), Dev нетдобавь Dev вручную или повтори, и возьми ops=${ops}`);
53
- log('добавь в src/routes.ts (dev подставь после):');
54
- log(` ${JSON.stringify(projectKey)}: { chat: '${chatId}', ops: ${ops}, dev: <вставь> },`);
52
+ log(`Ops created (id=${ops}), Dev was not add Dev by hand or run again, and keep ops=${ops}`);
53
+ log('add to src/routes.ts (fill dev in afterwards):');
54
+ log(` ${JSON.stringify(projectKey)}: { chat: '${chatId}', ops: ${ops}, dev: <fill in> },`);
55
55
  return;
56
56
  }
57
- log(`вкладки созданы: Ops=${ops}, Dev=${dev}`);
58
- log('добавь в src/routes.ts:');
57
+ log(`tabs created: Ops=${ops}, Dev=${dev}`);
58
+ log('add to src/routes.ts:');
59
59
  log(` ${JSON.stringify(projectKey)}: { chat: '${chatId}', ops: ${ops}, dev: ${dev} },`);
60
60
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.4.1",
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",