@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 +22 -16
- package/dist/cli-flags.js +3 -2
- package/dist/cli.js +70 -29
- package/dist/events.d.ts +132 -27
- package/dist/events.js +77 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/render.d.ts +10 -1
- package/dist/render.js +198 -80
- package/dist/routes.js +1 -1
- package/dist/send.js +21 -19
- package/dist/setup.js +9 -9
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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, '&')
|
|
@@ -46,12 +77,27 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
46
77
|
// Telegram отвечает на такое 400 — то есть карточка пропадала целиком, а
|
|
47
78
|
// отправитель с `|| true` этого не замечал. Нашёл Codex; воспроизводится
|
|
48
79
|
// отчётом с именем группы в 5000 знаков.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
208
|
-
|
|
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(
|
|
328
|
+
typeLine(iconFor(e), 'Report', e.title, e.url),
|
|
216
329
|
'',
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
340
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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(
|
|
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
|
-
|
|
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]
|
|
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
|
|
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('
|
|
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
|
|
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(
|
|
209
|
+
log(`failed to send the file: ${err.message}`);
|
|
210
210
|
return { outcome: 'fail' };
|
|
211
211
|
}
|
|
212
|
-
log(
|
|
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('
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
};
|