@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/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;')
@@ -46,12 +77,27 @@ export const clampMessage = (text, limit = 4000) => {
46
77
  // Telegram отвечает на такое 400 — то есть карточка пропадала целиком, а
47
78
  // отправитель с `|| true` этого не замечал. Нашёл Codex; воспроизводится
48
79
  // отчётом с именем группы в 5000 знаков.
49
- const tail = ['b', 'a', 'i', 'u', 'code', 'blockquote']
50
- .filter((t) => {
51
- const opened = (body.match(new RegExp(`<${t}[ >]`, 'g')) ?? []).length;
52
- const closed = (body.match(new RegExp(`</${t}>`, 'g')) ?? []).length;
53
- return opened > closed;
54
- })
80
+ //
81
+ // Порядок закрытия — обратный порядку ОТКРЫТИЯ, а не фиксированный список.
82
+ // Фиксированный список закрывал `<i><u>` как `</i></u>`: тегов поровну,
83
+ // счётчик сходится, вложенность нарушена, и Telegram отвечает тем же 400.
84
+ // Второй заход того же бага (25.08.2026), поэтому теперь порядок берётся из
85
+ // самого текста: последний открытый закрывается первым.
86
+ const open = [];
87
+ const tagRe = /<(\/?)(b|a|i|u|code|blockquote)[ >]/g;
88
+ for (let m = tagRe.exec(body); m !== null; m = tagRe.exec(body)) {
89
+ if (m[1] === '/') {
90
+ const at = open.lastIndexOf(m[2]);
91
+ if (at !== -1) {
92
+ open.splice(at, 1);
93
+ }
94
+ }
95
+ else {
96
+ open.push(m[2]);
97
+ }
98
+ }
99
+ const tail = open
100
+ .reverse()
55
101
  .map((t) => `</${t}>`)
56
102
  .join('');
57
103
  return `${body}${tail}\n…`;
@@ -103,6 +149,23 @@ const fieldLink = (label, url, text) => {
103
149
  const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'open')}</a>` : null;
104
150
  /** Моноширинное поле — путь/команда для копирования, не ссылка. */
105
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
+ };
106
169
  /** Заголовок группы: курсив + подчёркивание, без жирности, без двоеточия. */
107
170
  const group = (name) => `<i><u>${esc(cap(name))}</u></i>`;
108
171
  /** Позиция внутри группы: `<b>label:</b> <a>text</a>` — либо простая маркированная/нумерованная строка без label. */
@@ -125,6 +188,14 @@ const note = (text) => {
125
188
  const body = esc(text);
126
189
  return body.length > EXPAND_AT ? `<blockquote expandable>${body}</blockquote>` : `<blockquote>${body}</blockquote>`;
127
190
  };
191
+ /**
192
+ * Цитата с подписью. Голая цитата читается как продолжение поля над ней:
193
+ * владелец спросил про строку, которой открыта сессия, «что значит этот текст,
194
+ * откуда он берётся» — и был прав, в карточке это нигде не сказано. Подпись
195
+ * стоит отдельной строкой, потому что сам текст в поле не помещается: поле
196
+ * держит одну строку и обрезает.
197
+ */
198
+ const quoted = (label, text) => text ? `<b>${esc(cap(label))}</b>\n${note(text)}` : null;
128
199
  const join = (parts) => parts.filter((p) => p !== null).join('\n');
129
200
  /** Плоский список позиций (без ярлыков) — job/report без групп. */
130
201
  const bullets = (items, numbered) => (items ?? []).map((it, i) => groupItem(it, i, numbered));
@@ -148,19 +219,23 @@ const renderGroup = (g) => [
148
219
  */
149
220
  const titleField = (title) => field('Title', title);
150
221
  const bodyQuote = (body) => body ? note(body) : null;
151
- // Значок = статус сообщения, не тип события. Ровно четыре на весь пакет —
152
- // закреплённая легенда в форумах обещает это владельцу как факт, не как
153
- // приближение. 🔴 сломалось, 🚨 инцидент, ✅ прошло, ℹ️ к сведению.
154
- const ICON = { red: '🔴', alarm: '🚨', ok: '✅', info: 'ℹ️' };
222
+ // Значок и его закон живут в events.ts: от него зависит и звук.
155
223
  /** Строка 2: значок вне жирного, `<b>Тип:</b> действие` — то же поле, не особый случай. */
156
224
  // `action` объявлен строкой, но приходит и из `--json`, и из прямых вызовов на
157
225
  // JS, где типов нет. Пустое или отсутствующее значение давало строку `ℹ️ null`
158
226
  // прямо во второй строке карточки. Пустая строка честнее: поле просто исчезает.
159
- 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) => {
160
233
  // `field` возвращает null на пустом значении, а интерполяция null в шаблон
161
234
  // печатает слово «null». Так вторая строка карточки становилась `ℹ️ null` —
162
235
  // достижимо через `--json` и прямой вызов на JS, где типов нет.
163
- 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);
164
239
  return line === null ? `${icon} <b>${esc(cap(type))}</b>` : `${icon} ${line}`;
165
240
  };
166
241
  // `workflowUrl ?? url`: половина отправителей шлёт ссылку на прогон под именем
@@ -168,23 +243,45 @@ const typeLine = (icon, type, action) => {
168
243
  // только `workflowUrl`, поэтому красная карточка приходила БЕЗ ЕДИНОЙ ССЫЛКИ
169
244
  // на логи. Отвергать `--url` было бы честнее по имени и хуже по делу: намерение
170
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.
171
269
  const renderDeploy = (e) => {
172
- const icon = e.status === 'ok' ? ICON.ok : ICON.red;
270
+ const icon = iconFor(e);
271
+ const runUrl = e.workflowUrl ?? e.url;
173
272
  return join([
174
273
  typeLine(icon, 'Deploy', e.status),
274
+ fieldLink('Via', runUrl, mechanism(e.workflowName, e.via, runUrl)),
175
275
  '',
176
276
  fieldLink('Commit', e.commitUrl, e.commit),
177
277
  titleField(e.commitTitle),
178
278
  bodyQuote(e.commitBody),
179
- field('Via', e.via),
180
279
  field('Target', e.target),
181
- field('Reason', e.note),
182
- e.workflowUrl ?? e.url ? '' : null,
183
- fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
280
+ field('Reason', e.note)
184
281
  ]);
185
282
  };
186
283
  const renderJob = (e) => {
187
- const icon = e.status === 'fail' || e.status === 'disabled' ? ICON.red : ICON.ok;
284
+ const icon = iconFor(e);
188
285
  const hasItems = (e.items ?? []).length > 0;
189
286
  const disabledList = hasItems && e.status === 'disabled';
190
287
  return join([
@@ -195,8 +292,15 @@ const renderJob = (e) => {
195
292
  // repeating the label of the line right above it reads as a mistake.
196
293
  // Until now the name was dropped entirely — every caller passed it and the
197
294
  // owner only ever saw it as the small grey instance tag.
198
- 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),
199
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),
200
304
  ...(e.stats ?? []).map(([label, value]) => field(label, value)),
201
305
  hasItems ? '' : null,
202
306
  // Heading ONLY for `disabled`. It used to print for any job carrying a
@@ -204,68 +308,65 @@ const renderJob = (e) => {
204
308
  // "Disabled workflows".
205
309
  disabledList ? group('Disabled workflows') : null,
206
310
  ...(hasItems ? bullets(e.items, disabledList) : []),
207
- e.workflowUrl ?? e.url ? '' : null,
208
- 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
209
318
  ]);
210
319
  };
211
320
  const renderReport = (e) => {
212
321
  if (e.groups && e.groups.length > 0) {
213
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));
214
327
  return join([
215
- typeLine(ICON.info, 'Report', e.period ? `${e.title} · ${e.period}` : e.title),
328
+ typeLine(iconFor(e), 'Report', e.title, e.url),
216
329
  '',
217
- ...body,
218
- e.url ? '' : null,
219
- fieldAction('Details', e.url, undefined)
330
+ field('Period', e.period),
331
+ ...numbers,
332
+ body.length > 0 ? '' : null,
333
+ ...body
220
334
  ]);
221
335
  }
222
336
  const items = bullets(e.items, false);
223
337
  return join([
224
- 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),
225
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),
226
345
  ...(e.lines ?? []).map(([label, value]) => field(label, value)),
227
346
  items.length > 0 ? '' : null,
228
- ...items,
229
- // Обе аналитики шлют сюда ссылку на снимок дня в docs/. Рендер её не читал,
230
- // и дневной отчёт приходил без единственного способа посмотреть подробности.
231
- e.url ? '' : null,
232
- fieldAction('Details', e.url, undefined)
347
+ ...items
233
348
  ]);
234
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.
235
354
  const renderCi = (e) => {
236
- const icon = e.status === 'ok' ? ICON.ok : ICON.red;
355
+ const icon = iconFor(e);
356
+ const runUrl = e.workflowUrl ?? e.url;
237
357
  return join([
238
358
  typeLine(icon, 'CI', e.status),
359
+ fieldLink('Check', runUrl, mechanism(e.workflowName, undefined, runUrl)),
239
360
  '',
240
361
  fieldLink('Commit', e.commitUrl, e.commit),
241
362
  titleField(e.commitTitle),
242
363
  bodyQuote(e.commitBody),
243
364
  field('Actor', e.actor),
244
- field('Reason', e.note),
245
- e.workflowUrl ?? e.url ? '' : null,
246
- fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
365
+ field('Reason', e.note)
247
366
  ]);
248
367
  };
249
- // PR/Issue: значок теперь по статусу (четыре на пакет), не по действию —
250
- // `merged`/`approved` = успех, `changes_requested` = требует внимания,
251
- // остальное = к сведению. Слово действия само по себе уже говорит, что
252
- // произошло (`opened`, `ready_for_review` и т.д.), значок дублировать не должен.
253
- const PR_ICON = {
254
- opened: ICON.info,
255
- ready_for_review: ICON.info,
256
- review_requested: ICON.info,
257
- approved: ICON.ok,
258
- changes_requested: ICON.red,
259
- merged: ICON.ok,
260
- closed: ICON.info
261
- };
262
- const ISSUE_ICON = {
263
- opened: ICON.info,
264
- assigned: ICON.info,
265
- closed: ICON.ok
266
- };
267
368
  const renderPr = (e) => join([
268
- typeLine(PR_ICON[e.action], 'PR', e.action),
369
+ typeLine(iconFor(e), 'PR', e.action),
269
370
  '',
270
371
  // Идентификатор первым, заголовок под ним: так вещь читается «#118, вот
271
372
  // такая», а не «вот такая, кстати #118» — и так её пишет сам GitHub.
@@ -279,7 +380,7 @@ const renderPr = (e) => join([
279
380
  field('Reviewer', e.reviewer)
280
381
  ]);
281
382
  const renderIssue = (e) => join([
282
- typeLine(ISSUE_ICON[e.action], 'Issue', e.action),
383
+ typeLine(iconFor(e), 'Issue', e.action),
283
384
  '',
284
385
  fieldLink('Number', e.url, `#${e.number}`),
285
386
  titleField(e.title),
@@ -288,7 +389,7 @@ const renderIssue = (e) => join([
288
389
  field('Assignee', e.assignee)
289
390
  ]);
290
391
  const renderIncident = (e) => join([
291
- typeLine(ICON.alarm, 'Incident', 'open'),
392
+ typeLine(iconFor(e), 'Incident', 'open'),
292
393
  '',
293
394
  // `detail` is a diagnosis of several lines (vault greps three of them plus a
294
395
  // log path). It used to go through `field`, which keeps only the first line,
@@ -296,18 +397,33 @@ const renderIncident = (e) => join([
296
397
  // commit now: short label, full text quoted under it.
297
398
  // Ярлык `Title`, а не `Reason`: у аварии заголовок — такой же заголовок,
298
399
  // как у коммита и задачи, и называться в одной карточке он должен так же.
299
- 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),
300
403
  e.detail && e.detail !== e.title ? note(e.detail) : null,
301
- e.logs || e.url ? '' : null,
302
- fieldCode('Logs', e.logs),
303
- fieldAction('Workflow', e.url, undefined)
404
+ e.logs ? '' : null,
405
+ fieldCode('Logs', e.logs)
304
406
  ]);
305
407
  // Раньше всё это склеивалось в одну строку `Reason:` через тире: «имя — no
306
408
  // reports — expected X, last seen Y». Каждая другая карточка кладёт факт на
307
409
  // свою строку с ярлыком, и владелец справедливо спросил, зачем тут отдельный
308
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
+ ]);
309
425
  const renderHeartbeatMiss = (e) => {
310
- const icon = e.recovered ? ICON.ok : ICON.red;
426
+ const icon = iconFor(e);
311
427
  const action = e.recovered ? 'ok' : 'miss';
312
428
  return join([
313
429
  typeLine(icon, 'Heartbeat', action),
@@ -318,16 +434,6 @@ const renderHeartbeatMiss = (e) => {
318
434
  field(e.recovered ? 'Last run' : 'Last seen', e.lastSeen)
319
435
  ]);
320
436
  };
321
- // Подпись файла — та же карточка, но лимит Telegram у caption свой: 1024.
322
- const renderFile = (e) => join([
323
- typeLine(ICON.info, 'File', 'new'),
324
- '',
325
- // Раньше здесь стояло `field('Title', e.note ?? e.title)`: подпись файла
326
- // приходила под ярлыком заголовка, а сам заголовок из карточки исчезал.
327
- // Один ярлык — один смысл: Title это title, Reason это note.
328
- field('Title', e.title),
329
- field('Reason', e.note)
330
- ]);
331
437
  const RENDERERS = {
332
438
  deploy: renderDeploy,
333
439
  job: renderJob,
@@ -336,8 +442,8 @@ const RENDERERS = {
336
442
  pr: renderPr,
337
443
  issue: renderIssue,
338
444
  incident: renderIncident,
339
- heartbeat_miss: renderHeartbeatMiss,
340
- file: renderFile
445
+ session: renderSession,
446
+ heartbeat_miss: renderHeartbeatMiss
341
447
  };
342
448
  // Тег наверху карточки И машинный ключ разборщика — ОДНО И ТО ЖЕ значение
343
449
  // (решение владельца 20.08.2026): раньше это были два разных представления
@@ -346,7 +452,7 @@ const RENDERERS = {
346
452
  // разрывает Telegram-хэштег на середине слова (`#mac-config` линкуется
347
453
  // только как `#mac`), а тег ДОЛЖЕН быть кликабельным — это и есть фильтр
348
454
  // «показать всю историю этого экземпляра», которым владелец пользуется вживую.
349
- const slug = (raw) => raw
455
+ export const slug = (raw) => raw
350
456
  .toLowerCase()
351
457
  .replace(/[^\p{L}\p{N}]+/gu, '_')
352
458
  .replace(/^_+|_+$/g, '')
@@ -362,8 +468,8 @@ const TYPE_TAG = {
362
468
  pr: 'pr',
363
469
  issue: 'issue',
364
470
  incident: 'incident',
365
- heartbeat_miss: 'heartbeat',
366
- file: 'file'
471
+ session: 'session',
472
+ heartbeat_miss: 'heartbeat'
367
473
  };
368
474
  /**
369
475
  * Экземпляр-тег: что именно это конкретное событие (ветка, окружение,
@@ -385,8 +491,11 @@ export const eventKey = (e) => {
385
491
  return slug(e.job);
386
492
  case 'report':
387
493
  case 'incident':
388
- case 'file':
389
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);
390
499
  case 'pr':
391
500
  return `p${e.number}`;
392
501
  case 'issue':
@@ -396,6 +505,14 @@ export const eventKey = (e) => {
396
505
  return e.key ? slug(e.key) : fallback();
397
506
  };
398
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))}`;
399
516
  /**
400
517
  * Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
401
518
  * Теги — ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
@@ -409,12 +526,13 @@ export const render = (e) => {
409
526
  // строка, и неизвестное значение роняло процесс через `renderer is not a
410
527
  // function`. Падать из-за уведомления нельзя.
411
528
  if (typeof renderer !== 'function') {
412
- throw new Error(`неизвестный тип события: ${String(e.type)}`);
529
+ throw new Error(`unknown event type: ${String(e.type)}`);
413
530
  }
414
531
  const tags = tagsLine(e);
415
532
  // clampMessage может выйти за переданный limit на хвост закрывающих тегов и
416
533
  // многоточие — минус 40 оставляет ему этот запас. У сообщений свой запас уже
417
534
  // есть (4000 против 4096 у Telegram), у caption лимит 1024 настоящий.
418
- 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);
419
537
  return `${tags}\n${clampMessage(renderer(e), budget)}`;
420
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.
@@ -89,7 +89,7 @@ const attempt = async (token, target, text) => {
89
89
  // почему уведомления пропали, невозможно — а разбираться будет не
90
90
  // разработчик, а владелец.
91
91
  const detail = (await res.json().catch(() => null));
92
- log(`HTTP ${res.status}: ${detail?.description ?? 'без описания'} — не повторяем, ошибка постоянная`);
92
+ log(`HTTP ${res.status}: ${detail?.description ?? 'no description'} — permanent error, not retried`);
93
93
  return { outcome: 'fail' };
94
94
  }
95
95
  catch (err) {
@@ -99,7 +99,7 @@ const attempt = async (token, target, text) => {
99
99
  // на таймауте останавливаемся и честно пишем 'failed': лишняя копия аварии
100
100
  // хуже, чем пропущенная строка в логе, а сообщение, скорее всего, ушло.
101
101
  if (err instanceof Error && err.name === 'TimeoutError') {
102
- log('таймаут ответане повторяем: сообщение могло уже уйти');
102
+ log('answer timed out not retried: the message may already be out');
103
103
  return { outcome: 'fail' };
104
104
  }
105
105
  // Сюда попадают отказы соединения (DNS, TLS, сеть недоступна) — запрос
@@ -109,7 +109,7 @@ const attempt = async (token, target, text) => {
109
109
  // отсутствие дублей невозможно без idempotency-key у Bot API (его нет).
110
110
  // Логика прежняя: дубль на редком reset — меньшее зло, чем потеря
111
111
  // сообщения на частом сетевом сбое.
112
- log('fetch не прошёл, пробуем curl…');
112
+ log('fetch did not go through, trying curl…');
113
113
  const curl = sendViaCurl(token, target, text);
114
114
  return curl === 'retry' ? { outcome: 'retry', waitMs: 1000 } : { outcome: curl };
115
115
  }
@@ -130,14 +130,14 @@ const sendOne = async (token, target, text) => {
130
130
  }
131
131
  waitMs = result.waitMs;
132
132
  }
133
- log('исчерпаны попытки отправки');
133
+ log('out of send attempts');
134
134
  return 'failed';
135
135
  };
136
136
  /** Общий хвост для `notify` и `sendReport`: токен, цели, последовательная отправка. */
137
137
  const deliver = async (where, text) => {
138
138
  const token = process.env.OPS_BOT_TOKEN?.trim();
139
139
  if (!token) {
140
- log('нет OPS_BOT_TOKEN сообщение не отправлено');
140
+ log('skipped: no OPS_BOT_TOKEN, the message was not sent');
141
141
  return 'skipped';
142
142
  }
143
143
  // Токен interpolируется в URL и в curl-конфиг (`url = "...bot${token}..."`).
@@ -145,7 +145,7 @@ const deliver = async (where, text) => {
145
145
  // строки/`?` сломало бы разбор (инъекция директивы curl или query-хвост).
146
146
  // Это требует покорёженного секрета, но проверка копеечная.
147
147
  if (!/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
148
- log('OPS_BOT_TOKEN не похож на токен Telegram отправка отменена');
148
+ log('failed: OPS_BOT_TOKEN does not look like a Telegram token, send cancelled');
149
149
  return 'skipped';
150
150
  }
151
151
  if (where.length === 0) {
@@ -196,27 +196,27 @@ const sendFileOnce = async (token, target, e, caption) => {
196
196
  return { outcome: 'retry', waitMs: 1000 };
197
197
  }
198
198
  const detail = (await res.json().catch(() => null));
199
- log(`HTTP ${res.status}: ${detail?.description ?? 'без описания'} — не повторяем, ошибка постоянная`);
199
+ log(`HTTP ${res.status}: ${detail?.description ?? 'no description'} — permanent error, not retried`);
200
200
  return { outcome: 'fail' };
201
201
  }
202
202
  catch (err) {
203
203
  if (err instanceof Error && err.name === 'TimeoutError') {
204
- log('таймаут ответане повторяем: файл мог уже уйти');
204
+ log('answer timed out not retried: the file may already be out');
205
205
  return { outcome: 'fail' };
206
206
  }
207
207
  // Файл не читается (нет на диске, нет прав) — постоянная ошибка.
208
208
  if (err instanceof Error && 'code' in err) {
209
- log(`файл не отправлен: ${err.message}`);
209
+ log(`failed to send the file: ${err.message}`);
210
210
  return { outcome: 'fail' };
211
211
  }
212
- log(`сеть не пустила файл: ${err instanceof Error ? err.message : String(err)}`);
212
+ log(`the network refused the file: ${err instanceof Error ? err.message : String(err)}`);
213
213
  return { outcome: 'retry', waitMs: 1000 };
214
214
  }
215
215
  };
216
216
  const sendFile = async (e) => {
217
217
  const token = process.env.OPS_BOT_TOKEN?.trim();
218
218
  if (!token || !/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
219
- log('нет валидного OPS_BOT_TOKEN файл не отправлен');
219
+ log('skipped: no valid OPS_BOT_TOKEN, the file was not sent');
220
220
  return 'skipped';
221
221
  }
222
222
  const where = targets(e);
@@ -261,13 +261,13 @@ const sendFile = async (e) => {
261
261
  const reportLostProject = async (project, kind) => {
262
262
  // Локальный лог называет и допустимые написания — это единственная
263
263
  // диагностика, доступная на машине, где случилась опечатка.
264
- log(`неизвестный проект «${String(project)}»известны: ${Object.keys(ROUTES).join(', ')}`);
264
+ log(`unknown project "${String(project)}"known: ${Object.keys(ROUTES).join(', ')}`);
265
265
  const lost = {
266
266
  type: 'job',
267
267
  project: 'mac-config',
268
- job: 'notify: событие потеряно',
268
+ job: 'notify: an event was lost',
269
269
  status: 'fail',
270
- note: `проект «${String(project)}» не в ROUTES — событие «${kind}» никуда не доставлено`,
270
+ note: `project "${String(project)}" is not in ROUTES — event "${kind}" went nowhere`,
271
271
  key: 'notify-unknown-project'
272
272
  };
273
273
  await deliver(targets(lost), render(lost)).catch(() => undefined);
@@ -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
  };