@mikitasazan/notify 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lint.js CHANGED
@@ -141,9 +141,9 @@ export const lintCard = (html) => {
141
141
  if (/[а-яё]/i.test(strippedRows.join('\n'))) {
142
142
  found.push('Cyrillic outside quoted content — system text is English (rule L)');
143
143
  }
144
- // Rule S (v2.1): a card that reports trouble must say where to verify it —
145
- // a `Check:` command, a `Source:` link, a `To do:` command, or any link at
146
- // all. A `Log:` path alone is not enough: a path cannot be tapped, and a
144
+ // Rule S (v2.1, amended 03.09.2026): a card that reports trouble must say
145
+ // where to verify it — a `Check:` command, a `To do:` command, or any link
146
+ // at all (the link now rides on the name on line 2, not on a `Source:` row). A `Log:` path alone is not enough: a path cannot be tapped, and a
147
147
  // card whose only pointer needs a file manager is a card with no pointer.
148
148
  // `#fail` and `#unknown` both: a silent task has no log, but `config jobs
149
149
  // --log <key>` answers it too.
package/dist/render.d.ts CHANGED
@@ -37,6 +37,28 @@ export declare const esc: (v: unknown) => string;
37
37
  * permanent error and do not retry — the message disappeared for good.
38
38
  */
39
39
  export declare const clampMessage: (text: string, limit?: number, marker?: string) => string;
40
+ /**
41
+ * GitHub Markdown, read in Telegram. A PR or issue body arrives as the author
42
+ * wrote it for GitHub, and Telegram knows none of it: `## Как проверял` stood
43
+ * as two hash signs, a screenshot as `![after 375](https://…png)` in full, the
44
+ * PR template's HTML comment as a paragraph, a table as a fence of bars. On
45
+ * the 49 PR and issue cards of the week of 25.08.2026 that was the "wall of
46
+ * text" the owner read. So the body is translated, not pasted (03.09.2026):
47
+ *
48
+ * - a comment `<!-- … -->` is the template talking to the author — dropped;
49
+ * - `## Heading` → bold line; `**x**` → bold; `` `x` `` → code; a fenced
50
+ * block → `<pre>`;
51
+ * - an image `![alt](https://…)` → a link named by its alt (or `image`);
52
+ * a link `[text](https://…)` → a link; a link to a repo path (no scheme)
53
+ * keeps only its text — the address would not open from a phone anyway;
54
+ * - a table: the `|---|` rule is dropped, a row's cells are joined by ` · `;
55
+ * - `Closes #N` / `Fixes #N` lines are GitHub's own bookkeeping — dropped;
56
+ * - runs of blank lines collapse to one.
57
+ *
58
+ * Everything is escaped FIRST, then the markup is added on the escaped text:
59
+ * the patterns contain no `<>&"`, so nothing the author typed can become a tag.
60
+ */
61
+ export declare const markdownToTelegram: (text: string) => string;
40
62
  export declare const slug: (raw: string) => string;
41
63
  /**
42
64
  * The instance tag: exactly which concrete event this is (branch,
package/dist/render.js CHANGED
@@ -92,7 +92,7 @@ export const clampMessage = (text, limit = 4000, marker = '…') => {
92
92
  // bug (25.08.2026), so now the order comes from the text itself: the last
93
93
  // one opened is the first one closed.
94
94
  const open = [];
95
- const tagRe = /<(\/?)(b|a|i|u|code|blockquote)[ >]/g;
95
+ const tagRe = /<(\/?)(b|a|i|u|code|pre|blockquote)[ >]/g;
96
96
  for (let m = tagRe.exec(body); m !== null; m = tagRe.exec(body)) {
97
97
  if (m[1] === '/') {
98
98
  const at = open.lastIndexOf(m[2]);
@@ -167,6 +167,14 @@ const fieldLink = (label, url, text) => {
167
167
  };
168
168
  /** A monospace field — a path/command to copy, not a link. */
169
169
  const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
170
+ /**
171
+ * The owner himself, under the two names the senders know him by. A people
172
+ * row that names the reader is not news: on the 49 PR and issue cards of the
173
+ * week of 25.08.2026 `Author:` was him on every one, and the single row that
174
+ * ever said something was `Assignee: Ilja-Prihach`. So a person row is printed
175
+ * only when the person is someone else (03.09.2026).
176
+ */
177
+ const OWNER = { github: 'mikitasazan', telegram: 'chelsnebes' };
170
178
  /**
171
179
  * A person field — Author, Assignee, Reviewer. Every one of them is a
172
180
  * GitHub login (`github-cards.py` reads it off `.user.login`/`.assignee.login`
@@ -184,6 +192,9 @@ const fieldPerson = (label, login) => {
184
192
  return null;
185
193
  }
186
194
  const oneLine = firstLine(login);
195
+ if (oneLine.toLowerCase() === OWNER.github) {
196
+ return null;
197
+ }
187
198
  return `<b>${esc(cap(label))}:</b> <a href="https://github.com/${esc(oneLine)}">${esc(oneLine)}</a>`;
188
199
  };
189
200
  /**
@@ -200,7 +211,11 @@ const fieldTelegram = (label, handle) => {
200
211
  return null;
201
212
  }
202
213
  const oneLine = firstLine(handle);
203
- return `<b>${esc(cap(label))}:</b> <a href="https://t.me/${esc(oneLine.replace(/^@/, ''))}">${esc(oneLine)}</a>`;
214
+ const bare = oneLine.replace(/^@/, '');
215
+ if (bare.toLowerCase() === OWNER.telegram) {
216
+ return null;
217
+ }
218
+ return `<b>${esc(cap(label))}:</b> <a href="https://t.me/${esc(bare)}">${esc(oneLine)}</a>`;
204
219
  };
205
220
  /**
206
221
  * A row that asks something FROM THE OWNER, rather than reports a fact. It
@@ -244,9 +259,20 @@ const groupItem = (it, index, numbered) => {
244
259
  if (!it.facts || it.facts.length === 0) {
245
260
  return head;
246
261
  }
247
- const sub = it.facts.map(([label, value]) => ` <b>${esc(cap(label))}:</b> ${esc(String(value))}`);
262
+ const sub = it.facts
263
+ .filter(([, value]) => !isZeroStill(value))
264
+ .map(([label, value]) => ` <b>${esc(cap(label))}:</b> ${esc(String(value))}`);
248
265
  return [head, ...sub].join('\n');
249
266
  };
267
+ /**
268
+ * A zero that did not move says nothing. On game-publisher six of the eight
269
+ * analytics rows read `0 / 0 =` every single day, and the liveness check
270
+ * printed six rows of `0` nine days running (25.08–03.09.2026). The owner:
271
+ * silence is the report. So a row whose value is a bare `0`, or a zero
272
+ * compared with a zero, is not printed — and a group left with no rows loses
273
+ * its heading too. A zero that CHANGED (`0 / 3 ▼3`) still prints: that is news.
274
+ */
275
+ const isZeroStill = (value) => /^\s*0(?:[.,]0+)?\s*%?\s*(?:\/\s*0(?:[.,]0+)?\s*%?\s*=?\s*)?$/.test(String(value));
250
276
  // A long explanation (a note, incident details) — as a quote: in Telegram
251
277
  // that is a bar on the left and a light indent, reading as "details," not as
252
278
  // part of the heading. Longer than ~400 characters and the quote collapses
@@ -257,14 +283,118 @@ const groupItem = (it, index, numbered) => {
257
283
  // paragraph and the old length-only rule let them through (v2.1).
258
284
  const EXPAND_AT = 400;
259
285
  const EXPAND_LINES = 5;
260
- const note = (text) => {
261
- if (!text) {
262
- return null;
263
- }
264
- const body = esc(text);
286
+ /** The quote itself, over text that is ALREADY safe HTML. */
287
+ const quoteHtml = (body) => {
265
288
  const long = body.length > EXPAND_AT || body.split('\n').length > EXPAND_LINES;
266
289
  return long ? `<blockquote expandable>${body}</blockquote>` : `<blockquote>${body}</blockquote>`;
267
290
  };
291
+ const note = (text) => (text ? quoteHtml(esc(text)) : null);
292
+ /**
293
+ * GitHub Markdown, read in Telegram. A PR or issue body arrives as the author
294
+ * wrote it for GitHub, and Telegram knows none of it: `## Как проверял` stood
295
+ * as two hash signs, a screenshot as `![after 375](https://…png)` in full, the
296
+ * PR template's HTML comment as a paragraph, a table as a fence of bars. On
297
+ * the 49 PR and issue cards of the week of 25.08.2026 that was the "wall of
298
+ * text" the owner read. So the body is translated, not pasted (03.09.2026):
299
+ *
300
+ * - a comment `<!-- … -->` is the template talking to the author — dropped;
301
+ * - `## Heading` → bold line; `**x**` → bold; `` `x` `` → code; a fenced
302
+ * block → `<pre>`;
303
+ * - an image `![alt](https://…)` → a link named by its alt (or `image`);
304
+ * a link `[text](https://…)` → a link; a link to a repo path (no scheme)
305
+ * keeps only its text — the address would not open from a phone anyway;
306
+ * - a table: the `|---|` rule is dropped, a row's cells are joined by ` · `;
307
+ * - `Closes #N` / `Fixes #N` lines are GitHub's own bookkeeping — dropped;
308
+ * - runs of blank lines collapse to one.
309
+ *
310
+ * Everything is escaped FIRST, then the markup is added on the escaped text:
311
+ * the patterns contain no `<>&"`, so nothing the author typed can become a tag.
312
+ */
313
+ export const markdownToTelegram = (text) => {
314
+ const stripped = text.replace(/<!--[\s\S]*?-->/g, '');
315
+ const out = [];
316
+ let fence = null;
317
+ for (const raw of stripped.split('\n')) {
318
+ const line = esc(raw);
319
+ if (/^\s*```/.test(line)) {
320
+ if (fence) {
321
+ out.push(`<pre>${fence.join('\n')}</pre>`);
322
+ fence = null;
323
+ }
324
+ else {
325
+ fence = [];
326
+ }
327
+ continue;
328
+ }
329
+ if (fence) {
330
+ fence.push(line);
331
+ continue;
332
+ }
333
+ if (/^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line)) {
334
+ continue; // a table's header rule
335
+ }
336
+ if (/^\s*(closes|fixes|resolves)\s+#\d+\s*$/i.test(line)) {
337
+ continue;
338
+ }
339
+ let row = line;
340
+ const heading = row.match(/^\s*#{1,6}\s+(.*?)\s*#*\s*$/);
341
+ if (heading) {
342
+ row = `<b>${heading[1]}</b>`;
343
+ }
344
+ else if (/^\s*\|.*\|\s*$/.test(row)) {
345
+ row = row
346
+ .trim()
347
+ .slice(1, -1)
348
+ .split('|')
349
+ .map((c) => c.trim())
350
+ .filter((c) => c !== '')
351
+ .join(' · ');
352
+ }
353
+ row = row
354
+ .replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, (_, alt, url) => `<a href="${url}">${alt.trim() || 'image'}</a>`)
355
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2">$1</a>')
356
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
357
+ .replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
358
+ .replace(/`([^`\n]+)`/g, '<code>$1</code>');
359
+ out.push(row);
360
+ }
361
+ if (fence) {
362
+ out.push(`<pre>${fence.join('\n')}</pre>`);
363
+ }
364
+ return out
365
+ .join('\n')
366
+ .replace(/\n{3,}/g, '\n\n')
367
+ .trim();
368
+ };
369
+ /**
370
+ * The first section of a Markdown body: everything up to the SECOND `## `
371
+ * heading. On a merged or closed card the body is text he has already read
372
+ * when the thing was opened; the section that says what changed for the user
373
+ * is enough, the rest is one tap away behind the number.
374
+ */
375
+ const firstSection = (text) => {
376
+ const lines = text.replace(/<!--[\s\S]*?-->/g, '').split('\n');
377
+ let headings = 0;
378
+ const kept = [];
379
+ for (const line of lines) {
380
+ if (/^\s*#{1,6}\s+/.test(line)) {
381
+ headings += 1;
382
+ if (headings === 2) {
383
+ break;
384
+ }
385
+ }
386
+ kept.push(line);
387
+ }
388
+ return kept.join('\n');
389
+ };
390
+ /** A quoted Markdown body — translated, and optionally cut to its first section. */
391
+ const markdownQuote = (body, whole) => {
392
+ if (!body) {
393
+ return null;
394
+ }
395
+ const html = markdownToTelegram(whole ? body : firstSection(body));
396
+ return html ? quoteHtml(html) : null;
397
+ };
268
398
  /**
269
399
  * A quote with a caption. A bare quote reads as a continuation of the
270
400
  * field above it: the owner asked about the line that opens a session,
@@ -355,13 +485,21 @@ const renderGroup = (g) => [
355
485
  * could not read what the card was about without reading two lines.
356
486
  */
357
487
  const commitRow = (hash, url, title) => {
358
- const linked = fieldLink('Commit', url, hash);
359
- if (linked === null || !title) {
360
- return linked ?? field('Commit', title);
488
+ // The hash IS the link, and the title stands beside it: `Commit: <a>9b1fc68</a>
489
+ // · feat: …`. The hash spent two days (31.08–03.09.2026) as plain text in the
490
+ // middle of the card with its link parked in a `Source:` row at the bottom,
491
+ // where the same hash was printed a second time. The owner: "хэш можно
492
+ // сделать кликабельным" — a pointer is clickable where it stands, and the
493
+ // separate row that repeated it is gone.
494
+ if (!hash && !title) {
495
+ return null;
361
496
  }
362
- return `${linked} ${esc(firstLine(title))}`;
497
+ const head = hash ? (url ? `<a href="${esc(firstLine(url))}">${esc(firstLine(hash))}</a>` : esc(firstLine(hash))) : '';
498
+ const name = title ? esc(firstLine(title)) : '';
499
+ const value = head && name ? `${head} · ${name}` : head || name;
500
+ return `<b>Commit:</b> ${value}`;
363
501
  };
364
- const bodyQuote = (body) => body ? note(body) : null;
502
+ const bodyQuote = (body) => body ? markdownQuote(body, true) : null;
365
503
  /**
366
504
  * Labelled rows, sorted into the groups the sender itself named.
367
505
  *
@@ -381,7 +519,7 @@ const bodyQuote = (body) => body ? note(body) : null;
381
519
  * about the card itself, not about any one of its subjects.
382
520
  */
383
521
  const labelled = (rows) => {
384
- const list = rows ?? [];
522
+ const list = (rows ?? []).filter(([, value]) => !isZeroStill(value));
385
523
  const names = [...new Set(list.map(([, , g]) => g).filter((g) => !!g))];
386
524
  if (names.length === 0) {
387
525
  return list.map(([label, value]) => field(label, value)).filter((l) => l !== null);
@@ -514,8 +652,10 @@ const renderDeploy = (e) => {
514
652
  // enough: a plain 🔴 next to a workflow name still read as "something
515
653
  // happened," not "it failed," on a screen small enough to lose the color.
516
654
  // The `Via` row is gone: it used to carry this same name one floor below.
517
- // The run URL is gone from this line too it is the `Source:` row now.
518
- typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via), undefined, e.status === 'fail' ? 'Fail' : 'OK'),
655
+ // The run URL rides on the name (03.09.2026): the thing you read is the
656
+ // thing you tap. It spent three days in a `Source:` row at the bottom, and
657
+ // the owner asked what that row was for when the name was right there.
658
+ typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via), sourceUrl(e), e.status === 'fail' ? 'Fail' : 'OK'),
519
659
  ...twoBlocks([field('Target', e.target), reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
520
660
  commitRow(e.commit, e.commitUrl, e.commitTitle),
521
661
  fieldPerson('Author', e.commitAuthor),
@@ -542,10 +682,10 @@ const renderJob = (e) => {
542
682
  // icon says and what the third tag now says too. The icon table on the
543
683
  // catalogue page defines both marks.
544
684
  return join([
545
- // The URL moved off the type line into the `Source:` row of the pointer
546
- // block (v2.1, rule S): the owner asked for a pointer he can SEE, and a
547
- // link riding invisibly on the name is not one.
548
- typeLine(icon, 'Job', e.job, undefined, e.aside),
685
+ // The URL is on the name (03.09.2026). It went down to a `Source:` row in
686
+ // v2.1 so the pointer could be SEEN and came back, because the row's
687
+ // only text was `workflow run`, the same two words on every card.
688
+ typeLine(icon, 'Job', e.job, sourceUrl(e), e.aside),
549
689
  reason('Reason', e.note),
550
690
  field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
551
691
  // The timetable is a different subject from this event: how often the task
@@ -576,7 +716,7 @@ const renderReport = (e) => {
576
716
  // without a word.
577
717
  const numbers = labelled(e.lines);
578
718
  return join([
579
- typeLine(iconFor(e), 'Report', e.title, undefined, e.aside),
719
+ typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
580
720
  // Rows with no group of their own sit flush against the header instead of
581
721
  // forming a separate slab under a blank line. `labelled` puts the blank
582
722
  // line before the first group itself, so there is none here.
@@ -587,8 +727,9 @@ const renderReport = (e) => {
587
727
  }
588
728
  const items = bullets(e.items, false);
589
729
  return join([
590
- // The day's snapshot link is the `Source:` row now, same as every URL.
591
- typeLine(iconFor(e), 'Report', e.title, undefined, e.aside),
730
+ // The day's snapshot link is on the title — `Source: report` under a card
731
+ // headed Report named nothing.
732
+ typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
592
733
  // Flush against the header — see the branch above.
593
734
  ...labelled(e.lines),
594
735
  items.length > 0 ? '' : null,
@@ -603,8 +744,8 @@ const renderReport = (e) => {
603
744
  const renderCi = (e) => {
604
745
  const icon = iconFor(e);
605
746
  return join([
606
- // The run URL is the `Source:` row now, not an invisible link on the name.
607
- typeLine(icon, 'CI', mechanism(e.workflowName, undefined), undefined, e.status === 'fail' ? 'Fail' : 'OK'),
747
+ // The run URL is on the gate's name `CI: <a>nightly</a>` since 03.09.2026.
748
+ typeLine(icon, 'CI', mechanism(e.workflowName, undefined), sourceUrl(e), e.status === 'fail' ? 'Fail' : 'OK'),
608
749
  // `Actor` used to be read as "who wrote the commit," and on most runs it
609
750
  // is — `github.actor` for a push IS the person who pushed. It stops being
610
751
  // that on a scheduled run: arvent's nightly rewrites it to whoever is on
@@ -626,7 +767,38 @@ const renderCi = (e) => {
626
767
  // the thing the card is about could not be read without reading three lines.
627
768
  // The action is not repeated in words: the icon carries it, and no two actions
628
769
  // of one type share an icon.
629
- const named = (number, title) => title ? `#${number} ${title}` : `#${number}`;
770
+ // The number left line 2 on 31.08.2026, by the same rule that moved the commit
771
+ // hash the same day: `#347` is a pointer, and every pointer lives in the last
772
+ // block. Line 2 keeps the title, which is what the thing IS; the number comes
773
+ // back as the text of the `Source` link, where it names exactly what opens.
774
+ //
775
+ // With no title there is nothing left to say, so line 2 falls back to the bare
776
+ // type word — `typeLine` already does that on an empty name. The number is not
777
+ // put back here: it would then appear twice on a titled card and once on an
778
+ // untitled one, which is the inconsistency this move exists to remove.
779
+ //
780
+ // With NO url the number stays put, because then the `Source` row does not
781
+ // exist and dropping it here would erase the number from everything a person
782
+ // reads — the card would name a task without saying which. Same law as
783
+ // `fieldLink`: an identifier must not vanish just because the caller passed no
784
+ // address for it.
785
+ /**
786
+ * Line 2 of a pull request or an issue, since 03.09.2026:
787
+ *
788
+ * 🎉 <b>PR</b> <a>#414</a> · title
789
+ *
790
+ * The number is the link and it stands FIRST, right after the type word; the
791
+ * title follows a middle dot. The owner asked for exactly this: "номер
792
+ * перенести в title и сделать кликабельным … и разделить их как-то с
793
+ * title". The number had spent three days at the bottom as `Source: #414`,
794
+ * twenty lines under a title that could not be tapped. With no url the number
795
+ * still prints, plain. With no title the line ends at the number.
796
+ */
797
+ const numberedLine = (icon, type, number, title, url) => {
798
+ const id = url ? `<a href="${esc(firstLine(url))}">#${number}</a>` : `#${number}`;
799
+ const name = title?.trim() ? ` · ${esc(firstLine(title.trim()))}` : '';
800
+ return `${icon} <b>${esc(type)}</b> ${id}${name}`;
801
+ };
630
802
  // The people come BEFORE the text, and the text comes only when it is the
631
803
  // news. An `assigned` card carries one new fact — who took it — and it used to
632
804
  // sit dead last, under the issue's entire description: the owner read a card
@@ -647,14 +819,24 @@ const named = (number, title) => title ? `#${number} ${title}` : `#${number}`;
647
819
  // own comment there, not the PR description — and that is what the caption
648
820
  // has to say.
649
821
  const VERDICT = new Set(['approved', 'changes_requested']);
650
- const prBody = (action, body) => VERDICT.has(action) ? quoted('Review', body) : bodyQuote(body);
822
+ // The whole body is news exactly once — when the thing is opened, or when the
823
+ // text is a reviewer's own comment. On merged and closed the card keeps the
824
+ // first section only (what changed for the user); the rest is one tap away
825
+ // behind the number. Every body is Markdown from GitHub and is translated.
826
+ const prBody = (action, body) => {
827
+ if (VERDICT.has(action)) {
828
+ const html = markdownQuote(body, true);
829
+ return html ? `<b>Review:</b>\n${html}` : null;
830
+ }
831
+ return markdownQuote(body, action === 'opened');
832
+ };
651
833
  // The body is what the title stands for — it sits directly under the name,
652
834
  // with nothing between them. The people come after, consolidated in one
653
835
  // place, never splitting the title from what it names: the owner on the
654
836
  // old order, title then Author then Assignee then finally the body — "why
655
837
  // does the assignee cut apart what should be inseparable?"
656
838
  const renderPr = (e) => join([
657
- typeLine(iconFor(e), 'PR', named(e.number, e.title)),
839
+ numberedLine(iconFor(e), 'PR', e.number, e.title, e.url),
658
840
  prBody(e.action, e.body),
659
841
  e.body ? '' : null,
660
842
  fieldPerson('Author', e.author),
@@ -666,9 +848,11 @@ const renderPr = (e) => join([
666
848
  // полезно, не переходя по ссылке на источник." A collapsed quote costs four
667
849
  // lines, which is what the earlier "he already saw it" reasoning was trying
668
850
  // to save — the expandable quote buys the context back without the cost.
851
+ // Whole on opened and assigned (the text is still the news, or the person who
852
+ // took it needs the whole brief); the first section on closed — see prBody.
669
853
  const renderIssue = (e) => join([
670
- typeLine(iconFor(e), 'Issue', named(e.number, e.title)),
671
- bodyQuote(e.body),
854
+ numberedLine(iconFor(e), 'Issue', e.number, e.title, e.url),
855
+ markdownQuote(e.body, e.action !== 'closed'),
672
856
  e.body ? '' : null,
673
857
  fieldPerson('Author', e.author),
674
858
  fieldPerson('Assignee', e.assignee)
@@ -684,7 +868,7 @@ const renderIssue = (e) => join([
684
868
  const renderIncident = (e) => {
685
869
  const findings = bullets(e.items, false);
686
870
  return join([
687
- typeLine(iconFor(e), 'Incident', e.title),
871
+ typeLine(iconFor(e), 'Incident', e.title, e.url),
688
872
  e.detail && e.detail !== e.title ? note(e.detail) : null,
689
873
  field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
690
874
  findings.length > 0 ? '' : null,
@@ -833,36 +1017,25 @@ export const OUTCOME_TAG = {
833
1017
  export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)];
834
1018
  const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
835
1019
  /**
836
- * Rule S (v2.1): the card's last block says where to verify it. `Source:` is
837
- * a hyperlink when the event has a canonical URL; `Check:` is a local
838
- * command; `Log:` is a path and only ever an ADDITION a path cannot be
839
- * tapped, only copied. The link text is a short English noun naming what
840
- * opens, never the click.
1020
+ * Rule S (v2.1, amended 03.09.2026): the card says where to verify it. A
1021
+ * link rides on the NAME of the thing on line 2 (the run, the report, the
1022
+ * number of a PR or an issue) and on the commit hash; the last block keeps
1023
+ * only what is not a link `Check:` is a local command, `Log:` is a path,
1024
+ * and a path cannot be tapped, only copied.
1025
+ *
1026
+ * The `Source:` row that held the links from 31.08 to 03.09 is gone: its text
1027
+ * was `workflow run` on every deploy, `report` under every report, and a
1028
+ * number twenty lines below the title it belonged to. The owner: "Зачем это
1029
+ * всё? … Если ссылка подразумевается, она должна лежать сразу в title."
841
1030
  */
842
- const SOURCE_NAME = {
843
- deploy: 'workflow run',
844
- ci: 'workflow run',
845
- job: 'workflow run',
846
- report: 'report',
847
- pr: 'pull request',
848
- issue: 'issue',
849
- incident: 'details',
850
- session: 'details',
851
- heartbeat_miss: 'details'
852
- };
853
1031
  const sourceUrl = (e) => {
854
1032
  const wf = 'workflowUrl' in e ? e.workflowUrl : undefined;
855
1033
  const url = 'url' in e ? e.url : undefined;
856
1034
  return wf ?? url;
857
1035
  };
858
1036
  const pointerBlock = (e) => {
859
- const url = sourceUrl(e);
860
1037
  const logs = 'logs' in e ? e.logs : undefined;
861
- const rows = [
862
- fieldCode('Log', logs),
863
- fieldCode('Check', e.check),
864
- url ? `<b>Source:</b> <a href="${esc(url)}">${esc(SOURCE_NAME[e.type] ?? 'source')}</a>` : null
865
- ].filter((r) => r !== null);
1038
+ const rows = [fieldCode('Log', logs), fieldCode('Check', e.check)].filter((r) => r !== null);
866
1039
  return rows.length > 0 ? `\n\n${rows.join('\n')}` : '';
867
1040
  };
868
1041
  /**
@@ -876,8 +1049,24 @@ const cutMarker = (e) => {
876
1049
  if (e.path) {
877
1050
  return '⋯ cut, full text attached';
878
1051
  }
1052
+ // A bare `⋯ cut` announces a loss and then says nothing about it. The owner
1053
+ // met one on a live PR card (31.08.2026) — a description long enough to be
1054
+ // clamped — and asked what the three dots even referred to. The card had a
1055
+ // `Source` link the whole time: the rest was one tap away and the marker
1056
+ // never said so.
1057
+ //
1058
+ // So the marker NAMES the place that holds the rest, and never repeats its
1059
+ // value: it used to print the log path in full, and the pointer block then
1060
+ // printed the same path again on the very next line.
1061
+ //
1062
+ // `below` is literal, not a figure of speech: the marker closes the clamped
1063
+ // body and the pointer block is appended after it. `line 2` is literal too:
1064
+ // since 03.09.2026 the link lives on the name there.
879
1065
  const logs = 'logs' in e ? e.logs : undefined;
880
- return logs ? `⋯ cut, full: <code>${esc(logs)}</code>` : '⋯ cut';
1066
+ if (logs) {
1067
+ return '⋯ cut, full text at Log below';
1068
+ }
1069
+ return sourceUrl(e) ? '⋯ cut, full text behind the link on line 2' : '⋯ cut';
881
1070
  };
882
1071
  /**
883
1072
  * Renders an event into finished HTML text, cut to Telegram's limit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",