@mikitasazan/notify 1.4.1 → 1.4.2

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
@@ -114,8 +114,10 @@ const project = () => one('project');
114
114
  const PR_ALIASES = {
115
115
  opened: 'opened',
116
116
  reopened: 'opened',
117
- ready_for_review: 'ready_for_review',
118
- review_requested: 'review_requested',
117
+ // A PR that is already announced is not announced again: both of these mean
118
+ // "this PR now wants eyes", which is what `opened` already says.
119
+ ready_for_review: 'opened',
120
+ review_requested: 'opened',
119
121
  approved: 'approved',
120
122
  changes_requested: 'changes_requested',
121
123
  merged: 'merged',
@@ -165,6 +167,9 @@ const jobStatus = () => {
165
167
  if (raw === 'disabled') {
166
168
  return 'disabled';
167
169
  }
170
+ if (raw === 'silent') {
171
+ return 'silent';
172
+ }
168
173
  return raw === 'ok' || raw === 'success' || raw === 'passed' || raw === '0' ? 'ok' : 'fail';
169
174
  };
170
175
  let event;
@@ -206,9 +211,14 @@ else {
206
211
  project: project(),
207
212
  job: one('job') ?? '(no name)',
208
213
  status: jobStatus(),
214
+ expected: one('expected'),
215
+ lastSeen: one('last-seen'),
209
216
  stats: pairs('stat'),
210
217
  items: items(),
211
218
  note: one('note'),
219
+ command: one('command'),
220
+ commandNote: one('command-note'),
221
+ logs: one('logs'),
212
222
  workflowUrl: one('workflow-url'),
213
223
  workflowName: one('workflow-name'),
214
224
  url: one('url')
@@ -268,6 +278,21 @@ else {
268
278
  url: one('url')
269
279
  };
270
280
  break;
281
+ case 'session':
282
+ event = {
283
+ type: 'session',
284
+ project: project(),
285
+ action: one('action') ?? 'in trouble',
286
+ id: one('id'),
287
+ workdir: one('workdir'),
288
+ reason: one('reason'),
289
+ opened: one('opened'),
290
+ command: one('command'),
291
+ commandNote: one('command-note'),
292
+ // Only two states here, so `disabled` must not leak in from jobStatus.
293
+ status: jobStatus() === 'ok' ? 'ok' : 'fail'
294
+ };
295
+ break;
271
296
  case 'incident':
272
297
  event = {
273
298
  type: 'incident',
@@ -289,21 +314,19 @@ else {
289
314
  note: one('note')
290
315
  };
291
316
  break;
292
- case 'file': {
293
- const path = one('path');
294
- if (!path) {
295
- parseErrors.push('--path: required for a file event');
296
- }
317
+ // `file` is no longer a kind of event — an attachment is a property any
318
+ // card may have. The word is kept as an alias so senders that still say
319
+ // `notify file` deliver a report card with the log attached, instead of
320
+ // falling into the unknown-type branch and going silent.
321
+ case 'file':
297
322
  event = {
298
- type: 'file',
323
+ type: 'report',
299
324
  project: project(),
300
325
  title: one('title') ?? '(no title)',
301
- path: path ?? '',
302
- filename: one('filename'),
303
- note: one('note')
326
+ period: one('note'),
327
+ lines: []
304
328
  };
305
329
  break;
306
- }
307
330
  default:
308
331
  // В parseErrors, а не просто в лог: иначе неизвестный тип уходил в
309
332
  // тишину — событие не собиралось, ошибок разбора не было, и CLI выходил
@@ -316,6 +339,12 @@ else {
316
339
  // key в самом объекте, отсутствие флага не должно его затирать.
317
340
  if (event) {
318
341
  event.key = one('key') ?? event.key;
342
+ // --path is applicable to any type too, for the same reason --key is.
343
+ event.path = one('path') ?? event.path;
344
+ event.filename = one('filename') ?? event.filename;
345
+ }
346
+ if (event?.filename && !event.path) {
347
+ parseErrors.push('--filename: given without --path, so there is no file to name');
319
348
  }
320
349
  // Ошибки разбора — до отправки: лучше внятно сказать, что не так с командой,
321
350
  // чем прислать сообщение с «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,46 @@ 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';
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;
83
109
  stats?: Array<[label: string, value: string | number]>;
84
110
  /** Детали: что именно упало, замечания прогона; у `disabled` — список выключенных процессов (каждый со своей ссылкой). */
85
111
  items?: Item[];
86
112
  note?: string;
113
+ /**
114
+ * A command for him to run, rendered monospaced so Telegram makes it
115
+ * tap-to-copy. For the case where the card names something on this Mac
116
+ * that no URL can reach — a stopped local session, a latch file.
117
+ */
118
+ command?: string;
119
+ /**
120
+ * WHAT that command does. The owner, on a bare `rm` in a card: "я сейчас
121
+ * введу её и сделаю хуй пойми что, я ж не знаю, что делаю". A command he
122
+ * cannot read is one he cannot run, so it never travels alone.
123
+ */
124
+ commandNote?: string;
125
+ /**
126
+ * A local log path — monospaced, not a link, same as on an incident.
127
+ * It used to be glued onto the end of the reason sentence behind a
128
+ * colon, which is what made a red card read as one long run-on line.
129
+ */
130
+ logs?: string;
87
131
  workflowUrl?: string;
88
132
  /** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
89
133
  workflowName?: string;
@@ -153,7 +197,7 @@ export type NotifyEvent = Keyed & (
153
197
  | {
154
198
  type: 'pr';
155
199
  project: Project;
156
- action: 'opened' | 'ready_for_review' | 'review_requested' | 'approved' | 'changes_requested' | 'merged' | 'closed';
200
+ action: 'opened' | 'approved' | 'changes_requested' | 'merged' | 'closed';
157
201
  number: number;
158
202
  title: string;
159
203
  /** PR description — quoted on its own; the title is the `Title:` field above it. */
@@ -185,7 +229,47 @@ export type NotifyEvent = Keyed & (
185
229
  logs?: string;
186
230
  url?: string;
187
231
  }
188
- /** Задача не отметилась вовремя — сторож молчания (heartbeat). */
232
+ /**
233
+ * A working session on this Mac is in trouble — not a job, not a workflow.
234
+ * It went out as `job` at first and read wrong: `#job` promises something
235
+ * scheduled that ran and failed, and the owner rightly asked what a burning
236
+ * session was doing under that heading.
237
+ *
238
+ * What makes it its own type rather than an `incident`: a session has an
239
+ * identity nothing else here has — an id, a working directory, and the line
240
+ * he typed to start it, which is the ONLY thing that tells two of his open
241
+ * sessions apart.
242
+ */
243
+ | {
244
+ type: 'session';
245
+ project: Project;
246
+ /** What happened, as the second line reads it: `Session: burning the limit`. */
247
+ action: string;
248
+ /** The session's own id — the identifier field, first, as everywhere else. */
249
+ id?: string;
250
+ /** Working directory name, when several sessions opened with a similar line. */
251
+ workdir?: string;
252
+ /** One line of measurement: what the guard saw. */
253
+ reason?: string;
254
+ /**
255
+ * The line he opened the session with. Quoted, never a field: it is his
256
+ * own writing, it runs long, and a field would clip it to one short line
257
+ * — which is exactly how the first version of this card lost it.
258
+ */
259
+ opened?: string;
260
+ /** A command for him to run, monospaced so Telegram makes it copyable. */
261
+ command?: string;
262
+ /** WHAT that command does — see the note on `job.commandNote`. */
263
+ commandNote?: string;
264
+ /** `fail` red, `ok` green — a session that recovered is not an alarm. */
265
+ status?: 'fail' | 'ok';
266
+ }
267
+ /**
268
+ * УСТАРЕЛО с 1.4.2: используйте `job` со статусом `silent`. Тип остаётся,
269
+ * потому что сторож молчания живёт на сервере и до выкатки шлёт именно его —
270
+ * убрать значит потерять карточку молчания ровно тогда, когда она нужна.
271
+ * Новых вызовов не добавлять.
272
+ */
189
273
  | {
190
274
  type: 'heartbeat_miss';
191
275
  project: Project;
@@ -196,26 +280,47 @@ export type NotifyEvent = Keyed & (
196
280
  recovered?: boolean;
197
281
  /** Готовое предложение-причина; без него собирается из lastSeen/expected. */
198
282
  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
283
  });
218
284
  export type EventType = NotifyEvent['type'];
219
285
  /** Красное = со звуком. Всё остальное — тихо. (Отдельной темы «инциденты» больше нет — авария видна в ленте проекта.) */
286
+ /**
287
+ * Словарь значков. Два закона, и оба поставлены владельцем 25.08.2026:
288
+ *
289
+ * 1. ВНУТРИ одного тега у каждого слова свой значок. Раньше значков было
290
+ * ровно четыре на весь пакет, и `Issue: opened` с `Issue: assigned`
291
+ * выглядели одинаково, а у задачи три разных беды — fail, disabled,
292
+ * silent — были одним и тем же красным кругом.
293
+ * 2. МЕЖДУ тегами одинаковый смысл выглядит одинаково. `fail` — это 🔴 и в
294
+ * выкатке, и в CI, и в задаче; «появилось новое» — 🆕 и у задачи на доске,
295
+ * и у PR, и у файла.
296
+ *
297
+ * И третий, который держит первые два честными: у значка фиксированный звук.
298
+ * Не у события, не у статуса — у значка. Пока звук выводился отдельным
299
+ * правилом, `🔴 PR: changes_requested` приходила беззвучно.
300
+ */
301
+ export declare const ICON: {
302
+ readonly ok: "✅";
303
+ readonly red: "🔴";
304
+ readonly alarm: "🚨";
305
+ readonly off: "🚫";
306
+ readonly unknown: "❓";
307
+ readonly fresh: "🆕";
308
+ readonly taken: "🙋";
309
+ readonly landed: "🎉";
310
+ readonly discarded: "🗑️";
311
+ readonly approved: "👍";
312
+ readonly changes: "📝";
313
+ readonly info: "ℹ️";
314
+ };
315
+ /** The sound is a property of the icon, and of nothing else. */
316
+ export declare const LOUD: ReadonlySet<string>;
317
+ export declare const PR_ICON: Record<Extract<NotifyEvent, {
318
+ type: 'pr';
319
+ }>['action'], string>;
320
+ export declare const ISSUE_ICON: Record<Extract<NotifyEvent, {
321
+ type: 'issue';
322
+ }>['action'], string>;
323
+ /** Одно место, где решается значок карточки, — и рендер, и звук берут его отсюда. */
324
+ export declare const iconFor: (e: NotifyEvent) => string;
220
325
  export declare const severity: (e: NotifyEvent) => "info" | "error";
221
326
  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 { 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,6 +188,14 @@ 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
  };
191
+ /**
192
+ * Цитата с подписью. Голая цитата читается как продолжение поля над ней:
193
+ * владелец спросил про строку, которой открыта сессия, «что значит этот текст,
194
+ * откуда он берётся» — и был прав, в карточке это нигде не сказано. Подпись
195
+ * стоит отдельной строкой, потому что сам текст в поле не помещается: поле
196
+ * держит одну строку и обрезает.
197
+ */
198
+ const quoted = (label, text) => text ? `<b>${esc(cap(label))}</b>\n${note(text)}` : null;
143
199
  const join = (parts) => parts.filter((p) => p !== null).join('\n');
144
200
  /** Плоский список позиций (без ярлыков) — job/report без групп. */
145
201
  const bullets = (items, numbered) => (items ?? []).map((it, i) => groupItem(it, i, numbered));
@@ -163,19 +219,23 @@ const renderGroup = (g) => [
163
219
  */
164
220
  const titleField = (title) => field('Title', title);
165
221
  const bodyQuote = (body) => body ? note(body) : null;
166
- // Значок = статус сообщения, не тип события. Ровно четыре на весь пакет —
167
- // закреплённая легенда в форумах обещает это владельцу как факт, не как
168
- // приближение. 🔴 сломалось, 🚨 инцидент, ✅ прошло, ℹ️ к сведению.
169
- const ICON = { red: '🔴', alarm: '🚨', ok: '✅', info: 'ℹ️' };
222
+ // Значок и его закон живут в events.ts: от него зависит и звук.
170
223
  /** Строка 2: значок вне жирного, `<b>Тип:</b> действие` — то же поле, не особый случай. */
171
224
  // `action` объявлен строкой, но приходит и из `--json`, и из прямых вызовов на
172
225
  // JS, где типов нет. Пустое или отсутствующее значение давало строку `ℹ️ null`
173
226
  // прямо во второй строке карточки. Пустая строка честнее: поле просто исчезает.
174
- const typeLine = (icon, type, action) => {
227
+ // A link belongs on the NAME of the thing it opens, never on a separate row
228
+ // whose only text is the verb `open`. The owner read `Details: open` under a
229
+ // report and asked what "open" was — the answer is the report itself, which was
230
+ // sitting three lines above as dead text. So line 2 takes an optional URL and
231
+ // the action text becomes the link: `Report: <a>Analytics for 12.08</a>`.
232
+ const typeLine = (icon, type, action, url) => {
175
233
  // `field` возвращает null на пустом значении, а интерполяция null в шаблон
176
234
  // печатает слово «null». Так вторая строка карточки становилась `ℹ️ null` —
177
235
  // достижимо через `--json` и прямой вызов на JS, где типов нет.
178
- const line = field(type, action);
236
+ // `action || 'open'` in the linked case: an empty title must not swallow the
237
+ // link, which would be the one thing the card cannot afford to lose.
238
+ const line = url ? fieldLink(type, url, action || 'open') : field(type, action);
179
239
  return line === null ? `${icon} <b>${esc(cap(type))}</b>` : `${icon} ${line}`;
180
240
  };
181
241
  // `workflowUrl ?? url`: половина отправителей шлёт ссылку на прогон под именем
@@ -183,23 +243,45 @@ const typeLine = (icon, type, action) => {
183
243
  // только `workflowUrl`, поэтому красная карточка приходила БЕЗ ЕДИНОЙ ССЫЛКИ
184
244
  // на логи. Отвергать `--url` было бы честнее по имени и хуже по делу: намерение
185
245
  // однозначно, а карточка без ссылки бесполезна ровно тогда, когда нужна.
246
+ /**
247
+ * What to call the thing that ran. The workflow's own name first — it is the
248
+ * only text here that identifies THIS run. Then the caller's own word for the
249
+ * mechanism (`manual, from the Mac`). Last resort `the run`, and only when a
250
+ * link exists: losing the link to the logs on a red card is the one loss this
251
+ * format cannot afford, and a row that says nothing is still better than a
252
+ * card with nowhere to click. No live sender reaches that last resort — the
253
+ * GitHub Action always fills the workflow name, and the hand-run scripts send
254
+ * no run link at all.
255
+ */
256
+ const mechanism = (workflowName, via, runUrl) => workflowName ?? via ?? (runUrl ? 'the run' : undefined);
257
+ // The name of what ran sits WITH the type line, not eight lines below it.
258
+ // `Deploy: fail` and `by what means it ran` answer one question, and the owner
259
+ // read the two rows as unrelated things. It used to be one fact split in two:
260
+ // `Via: GitHub Actions` in the middle of the card and a trailing
261
+ // `Workflow: <run>` in the actions block. On one-q that trailing row rendered
262
+ // `Workflow: Deploy` — the link text repeating the word on line 2 and naming
263
+ // nothing.
264
+ //
265
+ // The link text is the workflow's OWN name, never the platform: `GitHub
266
+ // Actions` is identical on every card in every repository, so clicking it told
267
+ // the owner nothing about where he was going. `manual, from the Mac` stays
268
+ // unlinked, because a hand deploy has no run to open.
186
269
  const renderDeploy = (e) => {
187
- const icon = e.status === 'ok' ? ICON.ok : ICON.red;
270
+ const icon = iconFor(e);
271
+ const runUrl = e.workflowUrl ?? e.url;
188
272
  return join([
189
273
  typeLine(icon, 'Deploy', e.status),
274
+ fieldLink('Via', runUrl, mechanism(e.workflowName, e.via, runUrl)),
190
275
  '',
191
276
  fieldLink('Commit', e.commitUrl, e.commit),
192
277
  titleField(e.commitTitle),
193
278
  bodyQuote(e.commitBody),
194
- field('Via', e.via),
195
279
  field('Target', e.target),
196
- field('Reason', e.note),
197
- e.workflowUrl ?? e.url ? '' : null,
198
- fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
280
+ field('Reason', e.note)
199
281
  ]);
200
282
  };
201
283
  const renderJob = (e) => {
202
- const icon = e.status === 'fail' || e.status === 'disabled' ? ICON.red : ICON.ok;
284
+ const icon = iconFor(e);
203
285
  const hasItems = (e.items ?? []).length > 0;
204
286
  const disabledList = hasItems && e.status === 'disabled';
205
287
  return join([
@@ -210,8 +292,15 @@ const renderJob = (e) => {
210
292
  // repeating the label of the line right above it reads as a mistake.
211
293
  // Until now the name was dropped entirely — every caller passed it and the
212
294
  // owner only ever saw it as the small grey instance tag.
213
- field('Task', e.job),
295
+ // The run link rides on the task's own name. It used to sit at the bottom
296
+ // as `Workflow: open` — every job caller passes a URL and none passes a
297
+ // workflow name, so that row was the bare verb the owner objected to.
298
+ fieldLink('Task', e.workflowUrl ?? e.url, e.job),
214
299
  field('Reason', e.note),
300
+ field('Expected', e.expected),
301
+ // `Last run` when the task is alive, `Last seen` when it is not: the same
302
+ // timestamp answers two different questions.
303
+ field(e.status === 'silent' ? 'Last seen' : 'Last run', e.lastSeen),
215
304
  ...(e.stats ?? []).map(([label, value]) => field(label, value)),
216
305
  hasItems ? '' : null,
217
306
  // Heading ONLY for `disabled`. It used to print for any job carrying a
@@ -219,68 +308,65 @@ const renderJob = (e) => {
219
308
  // "Disabled workflows".
220
309
  disabledList ? group('Disabled workflows') : null,
221
310
  ...(hasItems ? bullets(e.items, disabledList) : []),
222
- e.workflowUrl ?? e.url ? '' : null,
223
- fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
311
+ e.command || e.logs ? '' : null,
312
+ fieldCode('Log', e.logs),
313
+ ...fieldRun(e.command, e.commandNote),
314
+ // Kept only when the caller actually names the workflow — a named row is a
315
+ // second, different destination; an unnamed one repeats the Task link.
316
+ e.workflowName && (e.workflowUrl ?? e.url) ? '' : null,
317
+ e.workflowName ? fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName) : null
224
318
  ]);
225
319
  };
226
320
  const renderReport = (e) => {
227
321
  if (e.groups && e.groups.length > 0) {
228
322
  const body = e.groups.flatMap((g, i) => (i === 0 ? renderGroup(g) : ['', ...renderGroup(g)]));
323
+ // `lines` и `groups` вместе, а не «или»: раньше ветка с группами печатала
324
+ // ТОЛЬКО группы, и цифры отчёта молча исчезали. Поймано 25.08.2026 при
325
+ // переводе утреннего отчёта PlayHub на типизированное событие.
326
+ const numbers = (e.lines ?? []).map(([label, value]) => field(label, value));
229
327
  return join([
230
- typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title),
328
+ typeLine(iconFor(e), 'Report', e.title, e.url),
231
329
  '',
232
- ...body,
233
- e.url ? '' : null,
234
- fieldAction('Details', e.url, undefined)
330
+ field('Period', e.period),
331
+ ...numbers,
332
+ body.length > 0 ? '' : null,
333
+ ...body
235
334
  ]);
236
335
  }
237
336
  const items = bullets(e.items, false);
238
337
  return join([
239
- typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title),
338
+ // Both analytics jobs send a link to the day's snapshot in docs/. It used to
339
+ // hang off a trailing `Details: open` row; now it is the report's own name.
340
+ typeLine(iconFor(e), 'Report', e.title, e.url),
240
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.
344
+ field('Period', e.period),
241
345
  ...(e.lines ?? []).map(([label, value]) => field(label, value)),
242
346
  items.length > 0 ? '' : null,
243
- ...items,
244
- // Обе аналитики шлют сюда ссылку на снимок дня в docs/. Рендер её не читал,
245
- // и дневной отчёт приходил без единственного способа посмотреть подробности.
246
- e.url ? '' : null,
247
- fieldAction('Details', e.url, undefined)
347
+ ...items
248
348
  ]);
249
349
  };
350
+ // Same law as the deploy card, one row up: what ran is named beside the type
351
+ // line and carries the link to its run. The label is `Check` and not `Via`
352
+ // because here the name answers WHICH gate spoke — `nightly`, `Quality` —
353
+ // while on a deploy it answers by what means the code was shipped.
250
354
  const renderCi = (e) => {
251
- const icon = e.status === 'ok' ? ICON.ok : ICON.red;
355
+ const icon = iconFor(e);
356
+ const runUrl = e.workflowUrl ?? e.url;
252
357
  return join([
253
358
  typeLine(icon, 'CI', e.status),
359
+ fieldLink('Check', runUrl, mechanism(e.workflowName, undefined, runUrl)),
254
360
  '',
255
361
  fieldLink('Commit', e.commitUrl, e.commit),
256
362
  titleField(e.commitTitle),
257
363
  bodyQuote(e.commitBody),
258
364
  field('Actor', e.actor),
259
- field('Reason', e.note),
260
- e.workflowUrl ?? e.url ? '' : null,
261
- fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
365
+ field('Reason', e.note)
262
366
  ]);
263
367
  };
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
368
  const renderPr = (e) => join([
283
- typeLine(PR_ICON[e.action], 'PR', e.action),
369
+ typeLine(iconFor(e), 'PR', e.action),
284
370
  '',
285
371
  // Идентификатор первым, заголовок под ним: так вещь читается «#118, вот
286
372
  // такая», а не «вот такая, кстати #118» — и так её пишет сам GitHub.
@@ -294,7 +380,7 @@ const renderPr = (e) => join([
294
380
  field('Reviewer', e.reviewer)
295
381
  ]);
296
382
  const renderIssue = (e) => join([
297
- typeLine(ISSUE_ICON[e.action], 'Issue', e.action),
383
+ typeLine(iconFor(e), 'Issue', e.action),
298
384
  '',
299
385
  fieldLink('Number', e.url, `#${e.number}`),
300
386
  titleField(e.title),
@@ -303,7 +389,7 @@ const renderIssue = (e) => join([
303
389
  field('Assignee', e.assignee)
304
390
  ]);
305
391
  const renderIncident = (e) => join([
306
- typeLine(ICON.alarm, 'Incident', 'open'),
392
+ typeLine(iconFor(e), 'Incident', 'open'),
307
393
  '',
308
394
  // `detail` is a diagnosis of several lines (vault greps three of them plus a
309
395
  // log path). It used to go through `field`, which keeps only the first line,
@@ -311,18 +397,33 @@ const renderIncident = (e) => join([
311
397
  // commit now: short label, full text quoted under it.
312
398
  // Ярлык `Title`, а не `Reason`: у аварии заголовок — такой же заголовок,
313
399
  // как у коммита и задачи, и называться в одной карточке он должен так же.
314
- titleField(e.title),
400
+ // Same rule as the report: the link rides on the incident's own title
401
+ // rather than on a trailing row whose only text is the word `open`.
402
+ fieldLink('Title', e.url, e.title),
315
403
  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)
404
+ e.logs ? '' : null,
405
+ fieldCode('Logs', e.logs)
319
406
  ]);
320
407
  // Раньше всё это склеивалось в одну строку `Reason:` через тире: «имя — no
321
408
  // reports — expected X, last seen Y». Каждая другая карточка кладёт факт на
322
409
  // свою строку с ярлыком, и владелец справедливо спросил, зачем тут отдельный
323
410
  // формат. Отдельного формата больше нет.
411
+ // A session in trouble. Same law as every other card: identifier first, then
412
+ // the facts as fields, then his own words as a quote — never as a field, which
413
+ // keeps one line and clipped the name of the very session the card is about.
414
+ const renderSession = (e) => join([
415
+ typeLine(iconFor(e), 'Session', e.action),
416
+ '',
417
+ field('Id', e.id),
418
+ field('Project', e.workdir),
419
+ field('Reason', e.reason),
420
+ e.opened ? '' : null,
421
+ quoted('Opened with', e.opened),
422
+ e.command ? '' : null,
423
+ ...fieldRun(e.command, e.commandNote)
424
+ ]);
324
425
  const renderHeartbeatMiss = (e) => {
325
- const icon = e.recovered ? ICON.ok : ICON.red;
426
+ const icon = iconFor(e);
326
427
  const action = e.recovered ? 'ok' : 'miss';
327
428
  return join([
328
429
  typeLine(icon, 'Heartbeat', action),
@@ -333,16 +434,6 @@ const renderHeartbeatMiss = (e) => {
333
434
  field(e.recovered ? 'Last run' : 'Last seen', e.lastSeen)
334
435
  ]);
335
436
  };
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
437
  const RENDERERS = {
347
438
  deploy: renderDeploy,
348
439
  job: renderJob,
@@ -351,8 +442,8 @@ const RENDERERS = {
351
442
  pr: renderPr,
352
443
  issue: renderIssue,
353
444
  incident: renderIncident,
354
- heartbeat_miss: renderHeartbeatMiss,
355
- file: renderFile
445
+ session: renderSession,
446
+ heartbeat_miss: renderHeartbeatMiss
356
447
  };
357
448
  // Тег наверху карточки И машинный ключ разборщика — ОДНО И ТО ЖЕ значение
358
449
  // (решение владельца 20.08.2026): раньше это были два разных представления
@@ -361,7 +452,7 @@ const RENDERERS = {
361
452
  // разрывает Telegram-хэштег на середине слова (`#mac-config` линкуется
362
453
  // только как `#mac`), а тег ДОЛЖЕН быть кликабельным — это и есть фильтр
363
454
  // «показать всю историю этого экземпляра», которым владелец пользуется вживую.
364
- const slug = (raw) => raw
455
+ export const slug = (raw) => raw
365
456
  .toLowerCase()
366
457
  .replace(/[^\p{L}\p{N}]+/gu, '_')
367
458
  .replace(/^_+|_+$/g, '')
@@ -377,8 +468,8 @@ const TYPE_TAG = {
377
468
  pr: 'pr',
378
469
  issue: 'issue',
379
470
  incident: 'incident',
380
- heartbeat_miss: 'heartbeat',
381
- file: 'file'
471
+ session: 'session',
472
+ heartbeat_miss: 'heartbeat'
382
473
  };
383
474
  /**
384
475
  * Экземпляр-тег: что именно это конкретное событие (ветка, окружение,
@@ -400,8 +491,11 @@ export const eventKey = (e) => {
400
491
  return slug(e.job);
401
492
  case 'report':
402
493
  case 'incident':
403
- case 'file':
404
494
  return slug(e.title);
495
+ // NOT the session id: an id is unique per session, so the tag would be
496
+ // new every time and nothing could ever be paired with anything.
497
+ case 'session':
498
+ return slug(e.action);
405
499
  case 'pr':
406
500
  return `p${e.number}`;
407
501
  case 'issue':
@@ -411,6 +505,14 @@ export const eventKey = (e) => {
411
505
  return e.key ? slug(e.key) : fallback();
412
506
  };
413
507
  const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))}`;
508
+ /**
509
+ * Строка тегов для свободного HTML (`sendReport`). Тег — это ФИЛЬТР владельца,
510
+ * и к формату тела он отношения не имеет: дневной отчёт остаётся свободным
511
+ * текстом, но перестаёт быть единственной карточкой без тегов. Раньше ключ
512
+ * висел хвостом в `<i><code>#ключ</code></i>` — это старый формат, до того как
513
+ * теги переехали первой строкой.
514
+ */
515
+ export const reportTags = (key) => `#report #${esc(slug(key))}`;
414
516
  /**
415
517
  * Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
416
518
  * Теги — ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
@@ -430,6 +532,7 @@ export const render = (e) => {
430
532
  // clampMessage может выйти за переданный limit на хвост закрывающих тегов и
431
533
  // многоточие — минус 40 оставляет ему этот запас. У сообщений свой запас уже
432
534
  // есть (4000 против 4096 у Telegram), у caption лимит 1024 настоящий.
433
- const budget = Math.max(64, e.type === 'file' ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
535
+ // Карточка с вложением это подпись, поэтому бюджет выбирается по `path`.
536
+ const budget = Math.max(64, e.path ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
434
537
  return `${tags}\n${clampMessage(renderer(e), budget)}`;
435
538
  };
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.2",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",