@mikitasazan/notify 1.11.0 → 1.12.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/render.js CHANGED
@@ -20,7 +20,6 @@
20
20
  * after every line.
21
21
  */
22
22
  import { ICON, iconFor } from "./events.js";
23
- /** First letter capitalized, the rest left as is (ga4/GitHub stay themselves). */
24
23
  /**
25
24
  * A label gets a capital letter — but NOT a name that is deliberately
26
25
  * written lowercase: `iOS` was turning into `IOS`. The signal is a capital
@@ -52,7 +51,7 @@ export const esc = (v) => String(v ?? '')
52
51
  * Telegram replies `400 can't parse entities`, and we treat a 4xx as a
53
52
  * permanent error and do not retry — the message disappeared for good.
54
53
  */
55
- export const clampMessage = (text, limit = 4000) => {
54
+ export const clampMessage = (text, limit = 4000, marker = '…') => {
56
55
  if (text.length <= limit) {
57
56
  return text;
58
57
  }
@@ -61,8 +60,14 @@ export const clampMessage = (text, limit = 4000) => {
61
60
  // Cut on a line boundary — only if that keeps most of the content.
62
61
  let end = lastBreak > limit * 0.6 ? lastBreak : limit;
63
62
  // Never cut inside `<...>` or inside `&...;` — otherwise the markup breaks.
63
+ // The closing `>` must be searched for only up to `end`, not across all of
64
+ // `cut`: a `>` that belongs to a LATER, already-doomed part of the message
65
+ // (still inside `cut` because `cut` runs to `limit`, past `end`) used to
66
+ // read as "this tag is closed" and let a tag get cut mid-attribute anyway —
67
+ // found by GLM review, reproduces with a long `href` that straddles the
68
+ // line-boundary cut point.
64
69
  const openTag = cut.lastIndexOf('<', end - 1);
65
- if (openTag !== -1 && cut.indexOf('>', openTag) === -1) {
70
+ if (openTag !== -1 && cut.slice(openTag, end).indexOf('>') === -1) {
66
71
  end = openTag;
67
72
  }
68
73
  const amp = cut.lastIndexOf('&', end - 1);
@@ -103,7 +108,12 @@ export const clampMessage = (text, limit = 4000) => {
103
108
  .reverse()
104
109
  .map((t) => `</${t}>`)
105
110
  .join('');
106
- return `${body}${tail}\n…`;
111
+ // The marker ANNOUNCES the cut instead of hiding it (v2.1): the caller
112
+ // reserves the marker's length out of `limit` before calling, so the
113
+ // marker itself can never be the thing that pushes the message over
114
+ // Telegram's hard cap — a free addition on top of 4000/4096 plus a long
115
+ // path was measured to earn a 400, and a 4xx is never retried.
116
+ return `${body}${tail}\n${marker}`;
107
117
  };
108
118
  // Only the first line: a field is single-line by contract (commit, branch,
109
119
  // author, a stat), not a place for a paragraph. A live case (18.08): a CI card
@@ -124,6 +134,21 @@ const firstLine = (value) => {
124
134
  * value serializes as `null`, not as a missing key.
125
135
  */
126
136
  const field = (label, value) => value === undefined || value === null || value === '' ? null : `<b>${esc(cap(label))}:</b> ${esc(firstLine(value))}`;
137
+ /**
138
+ * A Reason-shaped field: one line stays an ordinary field, several lines
139
+ * become a captioned quote — NOTHING is cut to the first line any more.
140
+ * `field`'s silent `firstLine` on a multi-line reason was the v1 contract,
141
+ * and it gutted every card whose failure did not fit one line (scp retries,
142
+ * a three-line diagnosis): the owner saw `Connection timed…` and nothing
143
+ * else. Confirmed live on four cards in the 14-day sweep, fixed in v2.1.
144
+ */
145
+ const reason = (label, value) => {
146
+ if (value === undefined || value === null || value === '') {
147
+ return null;
148
+ }
149
+ const text = String(value);
150
+ return text.includes('\n') ? quoted(label, text) : field(label, text);
151
+ };
127
152
  /**
128
153
  * An identifier field (`commit:`/`pr:`/`issue:`): the value is a link if
129
154
  * one exists, otherwise the plain text of the same field — an identifier
@@ -140,19 +165,43 @@ const fieldLink = (label, url, text) => {
140
165
  ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(firstLine(text))}</a>`
141
166
  : field(label, text);
142
167
  };
143
- /**
144
- * An action field (`workflow:`): unlike `fieldLink`, without a URL this is
145
- * NOT a field — the run simply has nowhere to lead, and showing the bare
146
- * word "run" with no link is more meaningless than not showing the row at
147
- * all.
148
- */
149
- // The link text is the name of where it leads (the workflow's name, the run's
150
- // name). The fallback word used to be "run": a noun that names nothing — the
151
- // owner read "Workflow: run" and did not understand what it was. "open" is a
152
- // verb, and it is at least honest about being a link, not a name.
153
- const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'open')}</a>` : null;
154
168
  /** A monospace field — a path/command to copy, not a link. */
155
169
  const fieldCode = (label, value) => value ? `<b>${esc(cap(label))}:</b> <code>${esc(value)}</code>` : null;
170
+ /**
171
+ * A person field — Author, Assignee, Reviewer. Every one of them is a
172
+ * GitHub login (`github-cards.py` reads it off `.user.login`/`.assignee.login`
173
+ * on the GitHub API object), and every GitHub login is a profile at one fixed
174
+ * address: `github.com/<login>`. The owner: "автор должен вести на страницу
175
+ * автора" — a name is an identifier like a commit hash or a PR number, and
176
+ * every other identifier on the card is a link.
177
+ */
178
+ // `firstLine`, same as `fieldLink`: a login with an embedded `\n` (found by
179
+ // Codex/GLM review) would otherwise land a raw newline inside the `href`
180
+ // itself, not just the link text — every other field guards against a
181
+ // multi-line value, this one didn't.
182
+ const fieldPerson = (label, login) => {
183
+ if (!login) {
184
+ return null;
185
+ }
186
+ const oneLine = firstLine(login);
187
+ return `<b>${esc(cap(label))}:</b> <a href="https://github.com/${esc(oneLine)}">${esc(oneLine)}</a>`;
188
+ };
189
+ /**
190
+ * `Actor` on a CI card is a Telegram handle, not a GitHub login
191
+ * (`nightly.yml`, step "Кто чинит" — "по «@chelsnebes» приходит уведомление
192
+ * тому, кто чинит, по «mikitasazan» — нет"), so it links to Telegram, not
193
+ * GitHub: `github.com/@chelsnebes` would open a page that does not exist.
194
+ * Telegram DOES auto-link a bare `@handle` on its own, but the owner asked
195
+ * for an explicit link like every other identifier on the card, not an
196
+ * implicit one riding on a client behavior he cannot see from here.
197
+ */
198
+ const fieldTelegram = (label, handle) => {
199
+ if (!handle) {
200
+ return null;
201
+ }
202
+ const oneLine = firstLine(handle);
203
+ return `<b>${esc(cap(label))}:</b> <a href="https://t.me/${esc(oneLine.replace(/^@/, ''))}">${esc(oneLine)}</a>`;
204
+ };
156
205
  /**
157
206
  * A row that asks something FROM THE OWNER, rather than reports a fact. It
158
207
  * already stood last, set off by a blank line, and still read as an
@@ -178,13 +227,25 @@ const fieldRun = (value, why) => {
178
227
  };
179
228
  /** A group heading: italic + underline, no bold, no colon. */
180
229
  const group = (name) => `<i><u>${esc(cap(name))}</u></i>`;
181
- /** An item inside a group: `<b>label:</b> <a>text</a>` — or a plain bulleted/numbered row with no label. */
230
+ /**
231
+ * An item inside a group: `<b>label:</b> <a>text</a>` — or a plain
232
+ * bulleted/numbered row with no label. `facts`, when the item carries them,
233
+ * print as indented `label: value` rows underneath — a nested list, one
234
+ * item with several facts of its own, the way a search query has both a
235
+ * click count and a position.
236
+ */
182
237
  const groupItem = (it, index, numbered) => {
183
238
  const linked = it.url ? `<a href="${esc(it.url)}">${esc(it.text)}</a>` : esc(it.text);
184
- if (it.label) {
185
- return `<b>${esc(it.label)}:</b> ${linked}`;
239
+ const head = it.label
240
+ ? `<b>${esc(cap(it.label))}:</b> ${linked}`
241
+ : numbered
242
+ ? `${index + 1}. ${linked}`
243
+ : `• ${linked}`;
244
+ if (!it.facts || it.facts.length === 0) {
245
+ return head;
186
246
  }
187
- return numbered ? `${index + 1}. ${linked}` : `• ${linked}`;
247
+ const sub = it.facts.map(([label, value]) => ` <b>${esc(cap(label))}:</b> ${esc(String(value))}`);
248
+ return [head, ...sub].join('\n');
188
249
  };
189
250
  // A long explanation (a note, incident details) — as a quote: in Telegram
190
251
  // that is a bar on the left and a light indent, reading as "details," not as
@@ -206,15 +267,16 @@ const note = (text) => {
206
267
  * the card says it nowhere. The caption stands on its own line, because
207
268
  * the text itself does not fit in a field: a field holds one line and cuts
208
269
  * it.
270
+ *
271
+ * The caption is a bold field label with nothing after the colon, not the
272
+ * group() heading (italic-underline). It was the group heading first, and
273
+ * the owner read that and said it did not look like a category — correctly:
274
+ * a group is a heading over a LIST, and this caption sits over one quote,
275
+ * never more than one. A bold label with no value on the line is the same
276
+ * shape `Title:` already has right before a PR's body — the reader has
277
+ * already seen this exact pattern mean "what follows is quoted text."
209
278
  */
210
- /**
211
- * A quote that needs saying what it is. The heading is a GROUP heading — the
212
- * same italic-underline every other card uses over a block — not a bold field
213
- * label: a bold label means `label: value` on one line, and using it here made
214
- * the session card the only one whose block was titled a third way. The owner
215
- * read the card and asked where its group was.
216
- */
217
- const quoted = (label, text) => text ? `${group(label)}\n${note(text)}` : null;
279
+ const quoted = (label, text) => text ? `<b>${esc(cap(label))}:</b>\n${note(text)}` : null;
218
280
  /**
219
281
  * Assembles the card. A blank line here marks a block change, not
220
282
  * indentation: two in a row mean an empty block, a leading one means a
@@ -421,14 +483,12 @@ const typeLine = (icon, type, action, url, aside) => {
421
483
  /**
422
484
  * What to call the thing that ran. The workflow's own name first — it is the
423
485
  * only text here that identifies THIS run. Then the caller's own word for the
424
- * mechanism (`manual, from the Mac`). Last resort `the run`, and only when a
425
- * link exists: losing the link to the logs on a red card is the one loss this
426
- * format cannot afford, and a row that says nothing is still better than a
427
- * card with nowhere to click. No live sender reaches that last resort — the
428
- * GitHub Action always fills the workflow name, and the hand-run scripts send
429
- * no run link at all.
486
+ * mechanism (`manual, from the Mac`). No third fallback: a row that says
487
+ * nothing distinctive is worse than not printing a name at all, and
488
+ * `typeLine` already handles the case with no name a bare `<b>Deploy</b>`,
489
+ * still linked when a run URL exists.
430
490
  */
431
- const mechanism = (workflowName, via, runUrl) => workflowName ?? via;
491
+ const mechanism = (workflowName, via) => workflowName ?? via;
432
492
  // The name of what ran sits WITH the type line, not eight lines below it.
433
493
  // `Deploy: fail` and `by what means it ran` answer one question, and the owner
434
494
  // read the two rows as unrelated things. It used to be one fact split in two:
@@ -443,14 +503,19 @@ const mechanism = (workflowName, via, runUrl) => workflowName ?? via;
443
503
  // unlinked, because a hand deploy has no run to open.
444
504
  const renderDeploy = (e) => {
445
505
  const icon = iconFor(e);
446
- const runUrl = e.workflowUrl ?? e.url;
447
506
  return join([
448
- // The name of what shipped the deploy sits on the type line. The outcome
449
- // is already said by the icon and the third tag; there is nothing to
450
- // repeat in words, and it is the same law a job and a report follow. The
451
- // `Via` row is gone: it used to carry this same name one floor below.
452
- typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via, runUrl), runUrl),
453
- ...twoBlocks([field('Target', e.target), field('Reason', e.note)], [commitRow(e.commit, e.commitUrl, e.commitTitle), bodyQuote(e.commitBody)])
507
+ // The name of what shipped the deploy sits on the type line, the outcome
508
+ // in parens beside it icon and tag alone were judged, live, not to be
509
+ // enough: a plain 🔴 next to a workflow name still read as "something
510
+ // happened," not "it failed," on a screen small enough to lose the color.
511
+ // The `Via` row is gone: it used to carry this same name one floor below.
512
+ // The run URL is gone from this line too — it is the `Source:` row now.
513
+ typeLine(icon, 'Deploy', mechanism(e.workflowName, e.via), undefined, e.status === 'fail' ? 'Fail' : 'OK'),
514
+ ...twoBlocks([field('Target', e.target), reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
515
+ commitRow(e.commit, e.commitUrl, e.commitTitle),
516
+ fieldPerson('Author', e.commitAuthor),
517
+ bodyQuote(e.commitBody)
518
+ ])
454
519
  ]);
455
520
  };
456
521
  const schedule = (expected, lastSeen, lastLabel) => {
@@ -472,27 +537,30 @@ const renderJob = (e) => {
472
537
  // icon says and what the third tag now says too. The icon table on the
473
538
  // catalogue page defines both marks.
474
539
  return join([
475
- typeLine(icon, 'Job', e.job, e.workflowUrl ?? e.url, e.aside),
476
- field('Reason', e.note),
540
+ // The URL moved off the type line into the `Source:` row of the pointer
541
+ // block (v2.1, rule S): the owner asked for a pointer he can SEE, and a
542
+ // link riding invisibly on the name is not one.
543
+ typeLine(icon, 'Job', e.job, undefined, e.aside),
544
+ reason('Reason', e.note),
545
+ field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
477
546
  // The timetable is a different subject from this event: how often the task
478
547
  // owes a sign of life and when it last gave one. It stood in a bare run
479
548
  // under `Reason:` and read as more of the same. `Last run` when the task
480
549
  // is alive, `Last seen` when it is not — one timestamp, two questions.
481
550
  ...schedule(e.expected, e.lastSeen, e.status === 'silent' ? 'Last seen' : 'Last run'),
482
551
  ...labelled(e.stats),
552
+ e.detail ? '' : null,
553
+ quoted(e.detailLabel ?? 'Detail', e.detail),
483
554
  hasItems ? '' : null,
484
555
  // Heading ONLY for `disabled`. It used to print for any job carrying a
485
556
  // list, so playhub's daily card of newly published games was headed
486
557
  // "Disabled workflows".
487
558
  disabledList ? group('Disabled workflows') : null,
488
559
  ...(hasItems ? bullets(e.items, disabledList) : []),
489
- e.command || e.logs ? '' : null,
490
- fieldCode('Log', e.logs),
560
+ e.command ? '' : null,
491
561
  ...fieldRun(e.command, e.commandNote)
492
- // No trailing `Workflow:` row. It pointed at `workflowUrl ?? url` the
493
- // exact address line 2 already carries so it was one destination
494
- // written twice, and it stood BELOW the `To do:` command, which is the
495
- // last thing the card is supposed to say.
562
+ // `Log:` left this body for the pointer block that `render` appends to
563
+ // every card the block a cut can never take.
496
564
  ]);
497
565
  };
498
566
  const renderReport = (e) => {
@@ -503,7 +571,7 @@ const renderReport = (e) => {
503
571
  // without a word.
504
572
  const numbers = labelled(e.lines);
505
573
  return join([
506
- typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
574
+ typeLine(iconFor(e), 'Report', e.title, undefined, e.aside),
507
575
  // Rows with no group of their own sit flush against the header instead of
508
576
  // forming a separate slab under a blank line. `labelled` puts the blank
509
577
  // line before the first group itself, so there is none here.
@@ -514,25 +582,37 @@ const renderReport = (e) => {
514
582
  }
515
583
  const items = bullets(e.items, false);
516
584
  return join([
517
- // Both analytics jobs send a link to the day's snapshot in docs/. It used to
518
- // hang off a trailing `Details: open` row; now it is the report's own name.
519
- typeLine(iconFor(e), 'Report', e.title, e.url, e.aside),
585
+ // The day's snapshot link is the `Source:` row now, same as every URL.
586
+ typeLine(iconFor(e), 'Report', e.title, undefined, e.aside),
520
587
  // Flush against the header — see the branch above.
521
588
  ...labelled(e.lines),
522
589
  items.length > 0 ? '' : null,
523
590
  ...items
524
591
  ]);
525
592
  };
526
- // Same law as the deploy card, one row up: what ran is named beside the type
527
- // line and carries the link to its run. The label is `Check` and not `Via`
528
- // because here the name answers WHICH gate spoke `nightly`, `Quality`
529
- // while on a deploy it answers by what means the code was shipped.
593
+ // Same law as the deploy card: what ran joins the type line itself and
594
+ // carries the link to its run `CI: nightly`, not a separate `Check:`/`Via:`
595
+ // row. There is no separate label here at all: on deploy the mechanism
596
+ // answers by what means the code was shipped, on CI it answers WHICH gate
597
+ // spoke (`nightly`, `Quality`) — but both are the name on the type line.
530
598
  const renderCi = (e) => {
531
599
  const icon = iconFor(e);
532
- const runUrl = e.workflowUrl ?? e.url;
533
600
  return join([
534
- typeLine(icon, 'CI', mechanism(e.workflowName, undefined, runUrl), runUrl),
535
- ...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [commitRow(e.commit, e.commitUrl, e.commitTitle), bodyQuote(e.commitBody)])
601
+ // The run URL is the `Source:` row now, not an invisible link on the name.
602
+ typeLine(icon, 'CI', mechanism(e.workflowName, undefined), undefined, e.status === 'fail' ? 'Fail' : 'OK'),
603
+ // `Actor` used to be read as "who wrote the commit," and on most runs it
604
+ // is — `github.actor` for a push IS the person who pushed. It stops being
605
+ // that on a scheduled run: arvent's nightly rewrites it to whoever is on
606
+ // duty to fix a red run, which can be someone other than the commit's
607
+ // author. So Actor answers "who is responsible for this run," `Author`
608
+ // below the commit answers "who wrote this code" — two different people
609
+ // on a nightly card, the same person everywhere else.
610
+ ...twoBlocks([reason('Reason', e.note), field('Still red', e.stillRed ? `day ${e.stillRed}` : null)], [
611
+ fieldTelegram('Actor', e.actor),
612
+ commitRow(e.commit, e.commitUrl, e.commitTitle),
613
+ fieldPerson('Author', e.commitAuthor),
614
+ bodyQuote(e.commitBody)
615
+ ])
536
616
  ]);
537
617
  };
538
618
  // A pull request and an issue are identified the way GitHub itself identifies
@@ -548,22 +628,42 @@ const named = (number, title) => title ? `#${number} ${title}` : `#${number}`;
548
628
  // about someone taking issue #312 and asked who, because he never got that far.
549
629
  //
550
630
  // The description is the news exactly once, when the thing is opened. On
551
- // assigned, closed, merged or a review verdict it is text he has already read,
552
- // and it buries the one line he came for.
553
- const opening = (action, body) => action === 'opened' ? bodyQuote(body) : null;
631
+ // assigned, closed or merged it is text he has already read, and it buries
632
+ // the one line he came for.
633
+ //
634
+ // A review verdict is the one exception: on `approved`/`changes_requested`
635
+ // `body` is not the PR's description any more — the sender puts the
636
+ // REVIEWER'S OWN comment there, which is new text he has not seen. "Verdict:
637
+ // changes_requested, then nothing" was the owner's complaint: a verdict with
638
+ // no comment attached said less than the review itself did.
639
+ const VERDICT = new Set(['approved', 'changes_requested']);
640
+ const opening = (action, body) => action === 'opened' || VERDICT.has(action) ? bodyQuote(body) : null;
641
+ // The body is what the title stands for — it sits directly under the name,
642
+ // with nothing between them. The people come after, consolidated in one
643
+ // place, never splitting the title from what it names: the owner on the
644
+ // old order, title then Author then Assignee then finally the body — "why
645
+ // does the assignee cut apart what should be inseparable?"
554
646
  const renderPr = (e) => join([
555
- typeLine(iconFor(e), 'PR', named(e.number, e.title), e.url),
556
- field('Author', e.author),
557
- field('Reviewer', e.reviewer),
558
- e.action === 'opened' && e.body ? '' : null,
559
- opening(e.action, e.body)
647
+ typeLine(iconFor(e), 'PR', named(e.number, e.title)),
648
+ opening(e.action, e.body),
649
+ (e.action === 'opened' || VERDICT.has(e.action)) && e.body ? '' : null,
650
+ fieldPerson('Author', e.author),
651
+ fieldPerson('Reviewer', e.reviewer)
560
652
  ]);
653
+ // The issue's body prints ONLY on `opened` — the same law the PR card
654
+ // follows. History of this line: hidden → shown everywhere (the owner's call
655
+ // of 26.08.2026, "inconsistent with the same card showing it moments
656
+ // earlier") → hidden again on 31.08.2026, when the owner, shown the live
657
+ // duplicate ("the full body arrives a second time in one day"), delegated
658
+ // the call ("сделай как лучше по оптимизации и простоте") and approved the
659
+ // short assigned card in the v2.1 mockups. On assigned/closed the card is
660
+ // the one new fact plus the `Source:` link to the full text.
561
661
  const renderIssue = (e) => join([
562
- typeLine(iconFor(e), 'Issue', named(e.number, e.title), e.url),
563
- field('Author', e.author),
564
- field('Assignee', e.assignee),
662
+ typeLine(iconFor(e), 'Issue', named(e.number, e.title)),
663
+ e.action === 'opened' ? bodyQuote(e.body) : null,
565
664
  e.action === 'opened' && e.body ? '' : null,
566
- opening(e.action, e.body)
665
+ fieldPerson('Author', e.author),
666
+ fieldPerson('Assignee', e.assignee)
567
667
  ]);
568
668
  // The incident's own title IS line 2, exactly as an issue's is. It used to say
569
669
  // the word `open` there — which the 🚨 already says, and no other card repeats
@@ -573,12 +673,17 @@ const renderIssue = (e) => join([
573
673
  // log path). It used to go through `field`, which keeps only the first line, so
574
674
  // every alarm this package ever sent arrived gutted. It is quoted now, the same
575
675
  // shape a commit body takes.
576
- const renderIncident = (e) => join([
577
- typeLine(iconFor(e), 'Incident', e.title, e.url),
578
- e.detail && e.detail !== e.title ? note(e.detail) : null,
579
- e.logs ? '' : null,
580
- fieldCode('Log', e.logs)
581
- ]);
676
+ const renderIncident = (e) => {
677
+ const findings = bullets(e.items, false);
678
+ return join([
679
+ typeLine(iconFor(e), 'Incident', e.title),
680
+ e.detail && e.detail !== e.title ? note(e.detail) : null,
681
+ field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
682
+ findings.length > 0 ? '' : null,
683
+ ...findings
684
+ // `Log:` moved to the pointer block `render` appends — see renderJob.
685
+ ]);
686
+ };
582
687
  // A session in trouble. Same law as every other card: identifier first, then
583
688
  // the facts as fields, then his own words as a quote — never as a field, which
584
689
  // keeps one line and clipped the name of the very session the card is about.
@@ -593,7 +698,8 @@ const renderSession = (e) => join([
593
698
  // opened a block that had no heading. Facts about the session touch the
594
699
  // line that names it, the way they do on every job card.
595
700
  field('Project', e.workdir),
596
- field('Reason', e.reason),
701
+ reason('Reason', e.reason),
702
+ field('Still red', e.stillRed ? `day ${e.stillRed}` : null),
597
703
  e.opened ? '' : null,
598
704
  quoted('Opened with', e.opened),
599
705
  e.command ? '' : null,
@@ -718,12 +824,62 @@ export const OUTCOME_TAG = {
718
824
  };
719
825
  export const outcomeTag = (e) => OUTCOME_TAG[iconFor(e)];
720
826
  const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
827
+ /**
828
+ * Rule S (v2.1): the card's last block says where to verify it. `Source:` is
829
+ * a hyperlink when the event has a canonical URL; `Check:` is a local
830
+ * command; `Log:` is a path and only ever an ADDITION — a path cannot be
831
+ * tapped, only copied. The link text is a short English noun naming what
832
+ * opens, never the click.
833
+ */
834
+ const SOURCE_NAME = {
835
+ deploy: 'workflow run',
836
+ ci: 'workflow run',
837
+ job: 'workflow run',
838
+ report: 'report',
839
+ pr: 'pull request',
840
+ issue: 'issue',
841
+ incident: 'details',
842
+ session: 'details',
843
+ heartbeat_miss: 'details'
844
+ };
845
+ const sourceUrl = (e) => {
846
+ const wf = 'workflowUrl' in e ? e.workflowUrl : undefined;
847
+ const url = 'url' in e ? e.url : undefined;
848
+ return wf ?? url;
849
+ };
850
+ const pointerBlock = (e) => {
851
+ const url = sourceUrl(e);
852
+ const logs = 'logs' in e ? e.logs : undefined;
853
+ const rows = [
854
+ fieldCode('Log', logs),
855
+ fieldCode('Check', e.check),
856
+ url ? `<b>Source:</b> <a href="${esc(url)}">${esc(SOURCE_NAME[e.type] ?? 'source')}</a>` : null
857
+ ].filter((r) => r !== null);
858
+ return rows.length > 0 ? `\n\n${rows.join('\n')}` : '';
859
+ };
860
+ /**
861
+ * The line that stands in for what the cut removed. It rides INSIDE the
862
+ * budget (the caller subtracts its length before clamping), so announcing
863
+ * the cut can never itself overflow the limit — with an attachment the
864
+ * limit is 1024, and the old flat 40-character margin did not fit a marker
865
+ * plus a path.
866
+ */
867
+ const cutMarker = (e) => {
868
+ if (e.path) {
869
+ return '⋯ cut, full text attached';
870
+ }
871
+ const logs = 'logs' in e ? e.logs : undefined;
872
+ return logs ? `⋯ cut, full: <code>${esc(logs)}</code>` : '⋯ cut';
873
+ };
721
874
  /**
722
875
  * Renders an event into finished HTML text, cut to Telegram's limit.
723
- * Tags are the FIRST line, added before the cut (not after, as before):
724
- * they carry both the human filter and the parser's machine key a card
725
- * cut without them would be not only unclickable but invisible to the
726
- * parser on exactly the longest, meaning the most important, messages.
876
+ *
877
+ * Assembly is TAIL-FIRST (v2.1): the parts that must survive any cutthe
878
+ * tag line (the human filter and the parser's machine key), the pointer
879
+ * block (`Log`/`Check`/`Source`) and the cut marker are measured before
880
+ * the body is clamped, and the body gets what is left. Under the old order
881
+ * the pointer was part of the body, so the longest cards lost exactly the
882
+ * line saying where to look.
727
883
  */
728
884
  export const render = (e) => {
729
885
  const renderer = RENDERERS[e.type];
@@ -734,11 +890,14 @@ export const render = (e) => {
734
890
  throw new Error(`unknown event type: ${String(e.type)}`);
735
891
  }
736
892
  const tags = tagsLine(e);
737
- // clampMessage can go past the passed limit for the tail of closing tags
738
- // and the ellipsis — minus 40 leaves it that margin. Messages already have
739
- // their own margin (4000 against Telegram's 4096); for a caption the 1024
740
- // limit is the real one. A card with an attachment is a caption, so the
741
- // budget is chosen by `path`.
742
- const budget = Math.max(64, e.path ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
743
- return `${tags}\n${clampMessage(renderer(e), budget)}`;
893
+ const pointer = pointerBlock(e);
894
+ const marker = cutMarker(e);
895
+ // clampMessage can go past the passed limit for the tail of closing tags —
896
+ // minus 40 leaves it that margin. Messages already have their own margin
897
+ // (4000 against Telegram's 4096); for a caption the 1024 limit is the real
898
+ // one. A card with an attachment is a caption, so the budget is chosen by
899
+ // `path`.
900
+ const limit = e.path ? 1024 : 4000;
901
+ const budget = Math.max(64, limit - tags.length - pointer.length - marker.length - 42);
902
+ return `${tags}\n${clampMessage(renderer(e), budget, marker)}${pointer}`;
744
903
  };
package/dist/send.d.ts CHANGED
@@ -1,3 +1,10 @@
1
1
  import type { NotifyEvent } from './events.ts';
2
2
  export type SendResult = 'sent' | 'skipped' | 'failed';
3
+ /** Exported for tests only — the time is injectable so day counting is provable. */
4
+ export declare const dedupe: (e: NotifyEvent, now?: number) => {
5
+ action: "send" | "suppress";
6
+ stillRed?: number;
7
+ };
8
+ /** Exported for tests: the watchdog's own card must provably pass the lint. */
9
+ export declare const brokenCardEvent: (e: NotifyEvent, faults: string[], offenderHtml: string) => NotifyEvent;
3
10
  export declare const notify: (e: NotifyEvent) => Promise<SendResult>;