@mikitasazan/notify 1.4.0 → 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
@@ -26,13 +26,25 @@ import { render } from "./render.js";
26
26
  import { notify } from "./send.js";
27
27
  import { setupTopic } from "./setup.js";
28
28
  const log = (msg) => console.error(`[notify] ${msg}`);
29
+ /**
30
+ * Anything taken from the caller and echoed into a message goes through this.
31
+ *
32
+ * `sent`, `failed` and `skipped` are a CONTRACT: `notify-fail.sh` greps
33
+ * `^\[notify\] sent$`, the server-side silence watchdog matches `*sent*`, and
34
+ * `action.yml` warns on `*failed*|*skipped*`. An input value carrying one of
35
+ * those words puts it on stderr — `notify sent --project=x` printed
36
+ * `unknown event type: sent`, and the watchdog read a card that was never
37
+ * delivered as delivered, then stopped repeating the alarm. Found by Codex,
38
+ * 25.08.2026.
39
+ */
40
+ const safe = (v) => String(v ?? '').replace(/\b(sent|failed|skipped)\b/gi, (w) => `${w[0]}·${w.slice(1)}`);
29
41
  const args = process.argv.slice(2);
30
42
  const command = args[0];
31
43
  if (command === 'setup') {
32
44
  const [, chatId, projectKey] = args;
33
45
  if (!chatId || !projectKey) {
34
- log('использование: notify setup <chat_id форума> <ключ-проекта>');
35
- log(' сначала создай группу, включи в ней «Темы» и добавь бота админом');
46
+ log('usage: notify setup <forum chat_id> <project key>');
47
+ log(' create the group first, turn Topics on in it, add the bot as admin');
36
48
  process.exit(0);
37
49
  }
38
50
  await setupTopic(chatId, projectKey);
@@ -45,7 +57,7 @@ const BOOLEAN_FLAGS = new Set(['json', 'recovered', 'dry-run']);
45
57
  for (let i = 1; i < args.length; i++) {
46
58
  const arg = args[i];
47
59
  if (!arg.startsWith('--')) {
48
- parseErrors.push(`stray argument with no flag: "${arg}"`);
60
+ parseErrors.push(`stray argument with no flag: "${safe(arg)}"`);
49
61
  continue;
50
62
  }
51
63
  // Форма `--key=value` обязательна для значений, начинающихся с `--`
@@ -67,7 +79,7 @@ for (let i = 1; i < args.length; i++) {
67
79
  // ТЕРЯЛОСЬ ВСЁ сообщение, а `--status` без значения рисовал 🔴 на
68
80
  // успешном деплое. Теперь это явная ошибка разбора.
69
81
  if (next === undefined || next.startsWith('--')) {
70
- parseErrors.push(`flag --${key} with no value`);
82
+ parseErrors.push(`flag --${safe(key)} with no value`);
71
83
  continue;
72
84
  }
73
85
  i++;
@@ -76,7 +88,7 @@ for (let i = 1; i < args.length; i++) {
76
88
  const one = (key) => flags.get(key)?.[0];
77
89
  for (const key of flags.keys()) {
78
90
  if (!KNOWN_FLAGS.has(key)) {
79
- parseErrors.push(`unknown flag --${key}`);
91
+ parseErrors.push(`unknown flag --${safe(key)}`);
80
92
  }
81
93
  }
82
94
  // Число с явной ошибкой разбора, иначе рендер рисовал «PR #NaN».
@@ -84,7 +96,7 @@ const num = (key) => {
84
96
  const raw = one(key);
85
97
  const n = Number(raw);
86
98
  if (raw === undefined || Number.isNaN(n)) {
87
- parseErrors.push(`--${key}: ожидается число, получено «${raw ?? ''}»`);
99
+ parseErrors.push(`--${safe(key)}: expected a number, got "${safe(raw)}"`);
88
100
  return 0;
89
101
  }
90
102
  return n;
@@ -102,8 +114,10 @@ const project = () => one('project');
102
114
  const PR_ALIASES = {
103
115
  opened: 'opened',
104
116
  reopened: 'opened',
105
- ready_for_review: 'ready_for_review',
106
- 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',
107
121
  approved: 'approved',
108
122
  changes_requested: 'changes_requested',
109
123
  merged: 'merged',
@@ -121,7 +135,7 @@ const ISSUE_ALIASES = {
121
135
  const prAction = (raw) => {
122
136
  const hit = PR_ALIASES[(raw ?? '').toLowerCase()];
123
137
  if (!hit) {
124
- parseErrors.push(`--action: unknown PR action "${raw ?? ''}" (${Object.keys(PR_ALIASES).join(', ')})`);
138
+ parseErrors.push(`--action: unknown PR action "${safe(raw)}" (${Object.keys(PR_ALIASES).join(', ')})`);
125
139
  return 'opened';
126
140
  }
127
141
  return hit;
@@ -153,6 +167,9 @@ const jobStatus = () => {
153
167
  if (raw === 'disabled') {
154
168
  return 'disabled';
155
169
  }
170
+ if (raw === 'silent') {
171
+ return 'silent';
172
+ }
156
173
  return raw === 'ok' || raw === 'success' || raw === 'passed' || raw === '0' ? 'ok' : 'fail';
157
174
  };
158
175
  let event;
@@ -166,7 +183,7 @@ if (flags.has('json')) {
166
183
  catch (err) {
167
184
  // Тоже в parseErrors: обе аналитики зовут CLI через `|| true`, и молчащий
168
185
  // разбор JSON означал бы зелёный крон без дневного отчёта.
169
- parseErrors.push(`--json from stdin did not parse: ${err instanceof Error ? err.message : String(err)}`);
186
+ parseErrors.push(`--json from stdin did not parse: ${safe(err instanceof Error ? err.message : err)}`);
170
187
  }
171
188
  }
172
189
  else {
@@ -192,11 +209,16 @@ else {
192
209
  event = {
193
210
  type: 'job',
194
211
  project: project(),
195
- job: one('job') ?? '(без имени)',
212
+ job: one('job') ?? '(no name)',
196
213
  status: jobStatus(),
214
+ expected: one('expected'),
215
+ lastSeen: one('last-seen'),
197
216
  stats: pairs('stat'),
198
217
  items: items(),
199
218
  note: one('note'),
219
+ command: one('command'),
220
+ commandNote: one('command-note'),
221
+ logs: one('logs'),
200
222
  workflowUrl: one('workflow-url'),
201
223
  workflowName: one('workflow-name'),
202
224
  url: one('url')
@@ -206,7 +228,7 @@ else {
206
228
  event = {
207
229
  type: 'report',
208
230
  project: project(),
209
- title: one('title') ?? '(без заголовка)',
231
+ title: one('title') ?? '(no title)',
210
232
  period: one('period'),
211
233
  lines: pairs('line'),
212
234
  items: items(),
@@ -236,7 +258,7 @@ else {
236
258
  project: project(),
237
259
  action: prAction(one('action')),
238
260
  number: num('number'),
239
- title: one('title') ?? '(без заголовка)',
261
+ title: one('title') ?? '(no title)',
240
262
  body: one('body'),
241
263
  author: one('author'),
242
264
  reviewer: one('reviewer'),
@@ -249,18 +271,33 @@ else {
249
271
  project: project(),
250
272
  action: issueAction(one('action')),
251
273
  number: num('number'),
252
- title: one('title') ?? '(без заголовка)',
274
+ title: one('title') ?? '(no title)',
253
275
  body: one('body'),
254
276
  author: one('author'),
255
277
  assignee: one('assignee'),
256
278
  url: one('url')
257
279
  };
258
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;
259
296
  case 'incident':
260
297
  event = {
261
298
  type: 'incident',
262
299
  project: project(),
263
- title: one('title') ?? '(без заголовка)',
300
+ title: one('title') ?? '(no title)',
264
301
  detail: one('detail'),
265
302
  logs: one('logs'),
266
303
  url: one('url')
@@ -270,33 +307,31 @@ else {
270
307
  event = {
271
308
  type: 'heartbeat_miss',
272
309
  project: project(),
273
- job: one('job') ?? '(без имени)',
310
+ job: one('job') ?? '(no name)',
274
311
  lastSeen: one('last-seen'),
275
312
  expected: one('expected'),
276
313
  recovered: flags.has('recovered'),
277
314
  note: one('note')
278
315
  };
279
316
  break;
280
- case 'file': {
281
- const path = one('path');
282
- if (!path) {
283
- parseErrors.push('--path: обязателен для file');
284
- }
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':
285
322
  event = {
286
- type: 'file',
323
+ type: 'report',
287
324
  project: project(),
288
- title: one('title') ?? '(без заголовка)',
289
- path: path ?? '',
290
- filename: one('filename'),
291
- note: one('note')
325
+ title: one('title') ?? '(no title)',
326
+ period: one('note'),
327
+ lines: []
292
328
  };
293
329
  break;
294
- }
295
330
  default:
296
331
  // В parseErrors, а не просто в лог: иначе неизвестный тип уходил в
297
332
  // тишину — событие не собиралось, ошибок разбора не было, и CLI выходил
298
333
  // нулём, ничего не отправив и ничего об этом не сказав.
299
- parseErrors.push(`unknown event type: ${command ?? '(none given)'}`);
334
+ parseErrors.push(`unknown event type: ${safe(command ?? '(none given)')}`);
300
335
  }
301
336
  }
302
337
  // --key применим к любому типу — одна точка вместо строки в каждом case
@@ -304,6 +339,12 @@ else {
304
339
  // key в самом объекте, отсутствие флага не должно его затирать.
305
340
  if (event) {
306
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');
307
348
  }
308
349
  // Ошибки разбора — до отправки: лучше внятно сказать, что не так с командой,
309
350
  // чем прислать сообщение с «true» вместо ссылки или 🔴 на успешном деплое.
@@ -343,7 +384,7 @@ if (event) {
343
384
  catch (err) {
344
385
  // Слово `failed` — контракт, тот же, что у ошибки разбора выше. Без него
345
386
  // исключение при отправке читалось сторожами как «ничего не случилось».
346
- log(`failed: ${err instanceof Error ? err.message : String(err)}`);
387
+ log(`failed: ${safe(err instanceof Error ? err.message : err)}`);
347
388
  }
348
389
  }
349
390
  process.exit(0);
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
  * Позиция списка внутри сообщения: задача из дайджеста, упавшая проверка,
@@ -49,12 +59,12 @@ export type NotifyEvent = Keyed & (
49
59
  commit?: string;
50
60
  /** Ссылка на коммит — строка «коммит» становится кликабельной. */
51
61
  commitUrl?: string;
52
- /** Заголовок коммита — рендерится рядом с телом в цитате. */
62
+ /** Заголовок коммита — рендерится полем `Title:`, тело идёт цитатой ниже. */
53
63
  commitTitle?: string;
54
64
  /** Тело коммита, если есть — та же цитата, что и заголовок. */
55
65
  commitBody?: string;
56
66
  workflowUrl?: string;
57
- /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
67
+ /** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
58
68
  workflowName?: string;
59
69
  url?: string;
60
70
  /**
@@ -78,14 +88,48 @@ 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
- /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
132
+ /** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
89
133
  workflowName?: string;
90
134
  /**
91
135
  * Запасное имя для ссылки на прогон: половина отправителей шлёт её как
@@ -139,9 +183,9 @@ export type NotifyEvent = Keyed & (
139
183
  * schedule, a manual press. Renders as `Reason:`, same as on deploy.
140
184
  */
141
185
  note?: string;
142
- /** Ссылка на прогон (workflow run) — отдельно от `url`, который у CI не используется. */
186
+ /** Ссылка на прогон (workflow run) — отдельно от `url` запасной для `workflowUrl`. */
143
187
  workflowUrl?: string;
144
- /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
188
+ /** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
145
189
  workflowName?: string;
146
190
  url?: string;
147
191
  }
@@ -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';