@mikitasazan/notify 1.4.1 → 1.4.3
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 +56 -13
- package/dist/events.d.ts +139 -24
- 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 +296 -87
- package/dist/routes.js +1 -1
- package/dist/send.js +7 -5
- package/dist/setup.js +9 -9
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -85,36 +85,42 @@ notify report --project playhub --json < payload.json # весь объект
|
|
|
85
85
|
| `pr` | событие пул-реквеста | `project`, `action`, `number`, `title` |
|
|
86
86
|
| `issue` | событие задачи | `project`, `action`, `number`, `title` |
|
|
87
87
|
| `incident` | приложение сломалось прямо сейчас | `project`, `title` |
|
|
88
|
-
| `
|
|
89
|
-
| `
|
|
88
|
+
| `session` | рабочая сессия на маке в беде | `project`, `action` |
|
|
89
|
+
| `heartbeat_miss` | УСТАРЕЛ, молчание — это `job --status silent` | `project`, `job` |
|
|
90
|
+
|
|
91
|
+
Отдельного вида «файл» нет: `--path` применим к ЛЮБОМУ событию, и тогда
|
|
92
|
+
карточка едет подписью к вложению (подпись у Telegram ограничена 1024 знаками,
|
|
93
|
+
а не 4000). Слово `file` осталось псевдонимом и собирает `report` с вложением —
|
|
94
|
+
старый отправитель не замолкает.
|
|
90
95
|
|
|
91
96
|
Полные сигнатуры — `src/events.ts`.
|
|
92
97
|
|
|
93
|
-
**Ключ задачи.**
|
|
94
|
-
По
|
|
98
|
+
**Ключ задачи.** Первая строка каждой карточки — два тега: `#тип #ключ`.
|
|
99
|
+
По ним дневной разборщик сверяет «это 🔴 уже закрыто более поздней карточкой
|
|
95
100
|
той же задачи?» без сравнения человеческих формулировок. Явный `--key`
|
|
96
101
|
побеждает; без него ключ выводится из заголовка и меняется вместе с ним —
|
|
97
102
|
регулярный отправитель передаёт `--key` явно. В stdout CLI ключ не попадает.
|
|
98
103
|
|
|
99
104
|
**Единственная дверь для свободного HTML** — `sendReport()` (`report --json` у
|
|
100
105
|
аналитик): плотную строку дневного отчёта не разложить в `label=value` не
|
|
101
|
-
испортив. Стандартизирован транспорт, формат — нет, и только здесь.
|
|
106
|
+
испортив. Стандартизирован транспорт, формат тела — нет, и только здесь.
|
|
107
|
+
Строку тегов `#report #ключ` пакет ставит и здесь: тег — фильтр владельца, к
|
|
108
|
+
формату тела он отношения не имеет. Без явного ключа берётся `daily`.
|
|
102
109
|
|
|
103
110
|
**Неизвестный проект** не роняет вызвавший крон (код возврата 0), но больше и
|
|
104
111
|
не исчезает молча: в mac-config Ops уходит красная карточка «notify: событие
|
|
105
112
|
потеряно». До 18.08.2026 опечатка в `--project` терялась без следа неделями.
|
|
106
113
|
|
|
107
|
-
`--action` у `pr`: `opened`, `
|
|
108
|
-
`
|
|
109
|
-
|
|
110
|
-
`
|
|
111
|
-
подмена: иначе «запрошены правки» приехали бы как «открыт».
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
`
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
три карточки подряд. Правило владельца от 27.07.2026 — одна новость, одна
|
|
114
|
+
`--action` у `pr`: `opened`, `approved`, `changes_requested`, `merged`,
|
|
115
|
+
`closed`. У `issue`: `opened`, `assigned`, `closed`. Сырые имена GitHub, которые
|
|
116
|
+
означают то же самое, сводятся к ним: `reopened`, `ready_for_review` и
|
|
117
|
+
`review_requested` — это всё `opened`. Неизвестное действие — ошибка разбора, а
|
|
118
|
+
не молчаливая подмена: иначе «запрошены правки» приехали бы как «открыт».
|
|
119
|
+
|
|
120
|
+
**Из GitHub в «⚙️ Ops» уходит не всё, что там происходит.** Общий workflow
|
|
121
|
+
`.github/workflows/ops-notify.yml` отбрасывает `ready_for_review` и
|
|
122
|
+
`review_requested` ещё на входе: оба сообщают про PR, который уже объявлен
|
|
123
|
+
открытым, и открытие одного PR давало три карточки подряд. Правило владельца от 27.07.2026 — одна новость, одна
|
|
118
124
|
карточка: **открыт → вердикт ревью (👍 / 📝) → закрыт или смёржен**. Белый список
|
|
119
125
|
живёт в общем workflow, а не в подписках проектов, потому что подписка обязана
|
|
120
126
|
лежать в вызывающем репозитории и в четырёх проектах расходится сама собой;
|
package/dist/cli-flags.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export const KNOWN_FLAGS = new Set([
|
|
12
12
|
'action', 'actor', 'assignee', 'author', 'body', 'branch', 'commit',
|
|
13
|
-
'
|
|
14
|
-
'
|
|
13
|
+
'id', 'opened', 'reason', 'workdir',
|
|
14
|
+
'command', 'command-note', 'commit-body', 'commit-title', 'commit-url', 'detail',
|
|
15
|
+
'expected', 'filename', 'item', 'job', 'key', 'last-seen', 'line', 'logs', 'note',
|
|
15
16
|
'number', 'path', 'period', 'project', 'reviewer', 'stat', 'status',
|
|
16
17
|
'target', 'title', 'url', 'via', 'workflow-name', 'workflow-url',
|
|
17
18
|
// Флаги без значения. Живут здесь же, чтобы разбор и список не разошлись.
|
package/dist/cli.js
CHANGED
|
@@ -106,16 +106,32 @@ const items = () => (flags.get('item') ?? []).map((raw) => {
|
|
|
106
106
|
const idx = raw.lastIndexOf('|');
|
|
107
107
|
return idx === -1 ? { text: raw } : { text: raw.slice(0, idx), url: raw.slice(idx + 1) };
|
|
108
108
|
});
|
|
109
|
+
/**
|
|
110
|
+
* `--stat "label=value"`, и с 25.08.2026 — `--stat "Group | label=value"`:
|
|
111
|
+
* имя группы, вертикальная черта, ярлык. Черта выбрана потому, что её нет ни
|
|
112
|
+
* в одном живом ярлыке, а двоеточие есть («Eval: bot answer quality») и
|
|
113
|
+
* равенство занято значением. Пробелы вокруг черты необязательны.
|
|
114
|
+
*
|
|
115
|
+
* Без черты всё как было — так шлют больше двадцати отправителей, и ни один
|
|
116
|
+
* из них менять не нужно.
|
|
117
|
+
*/
|
|
109
118
|
const pairs = (key) => (flags.get(key) ?? []).map((s) => {
|
|
110
119
|
const idx = s.indexOf('=');
|
|
111
|
-
|
|
120
|
+
const head = idx === -1 ? s : s.slice(0, idx);
|
|
121
|
+
const value = idx === -1 ? '' : s.slice(idx + 1);
|
|
122
|
+
const bar = head.indexOf('|');
|
|
123
|
+
return bar === -1
|
|
124
|
+
? [head, value]
|
|
125
|
+
: [head.slice(bar + 1).trim(), value, head.slice(0, bar).trim()];
|
|
112
126
|
});
|
|
113
127
|
const project = () => one('project');
|
|
114
128
|
const PR_ALIASES = {
|
|
115
129
|
opened: 'opened',
|
|
116
130
|
reopened: 'opened',
|
|
117
|
-
|
|
118
|
-
|
|
131
|
+
// A PR that is already announced is not announced again: both of these mean
|
|
132
|
+
// "this PR now wants eyes", which is what `opened` already says.
|
|
133
|
+
ready_for_review: 'opened',
|
|
134
|
+
review_requested: 'opened',
|
|
119
135
|
approved: 'approved',
|
|
120
136
|
changes_requested: 'changes_requested',
|
|
121
137
|
merged: 'merged',
|
|
@@ -165,6 +181,9 @@ const jobStatus = () => {
|
|
|
165
181
|
if (raw === 'disabled') {
|
|
166
182
|
return 'disabled';
|
|
167
183
|
}
|
|
184
|
+
if (raw === 'silent') {
|
|
185
|
+
return 'silent';
|
|
186
|
+
}
|
|
168
187
|
return raw === 'ok' || raw === 'success' || raw === 'passed' || raw === '0' ? 'ok' : 'fail';
|
|
169
188
|
};
|
|
170
189
|
let event;
|
|
@@ -206,9 +225,14 @@ else {
|
|
|
206
225
|
project: project(),
|
|
207
226
|
job: one('job') ?? '(no name)',
|
|
208
227
|
status: jobStatus(),
|
|
228
|
+
expected: one('expected'),
|
|
229
|
+
lastSeen: one('last-seen'),
|
|
209
230
|
stats: pairs('stat'),
|
|
210
231
|
items: items(),
|
|
211
232
|
note: one('note'),
|
|
233
|
+
command: one('command'),
|
|
234
|
+
commandNote: one('command-note'),
|
|
235
|
+
logs: one('logs'),
|
|
212
236
|
workflowUrl: one('workflow-url'),
|
|
213
237
|
workflowName: one('workflow-name'),
|
|
214
238
|
url: one('url')
|
|
@@ -268,6 +292,21 @@ else {
|
|
|
268
292
|
url: one('url')
|
|
269
293
|
};
|
|
270
294
|
break;
|
|
295
|
+
case 'session':
|
|
296
|
+
event = {
|
|
297
|
+
type: 'session',
|
|
298
|
+
project: project(),
|
|
299
|
+
action: one('action') ?? 'in trouble',
|
|
300
|
+
id: one('id'),
|
|
301
|
+
workdir: one('workdir'),
|
|
302
|
+
reason: one('reason'),
|
|
303
|
+
opened: one('opened'),
|
|
304
|
+
command: one('command'),
|
|
305
|
+
commandNote: one('command-note'),
|
|
306
|
+
// Only two states here, so `disabled` must not leak in from jobStatus.
|
|
307
|
+
status: jobStatus() === 'ok' ? 'ok' : 'fail'
|
|
308
|
+
};
|
|
309
|
+
break;
|
|
271
310
|
case 'incident':
|
|
272
311
|
event = {
|
|
273
312
|
type: 'incident',
|
|
@@ -289,21 +328,19 @@ else {
|
|
|
289
328
|
note: one('note')
|
|
290
329
|
};
|
|
291
330
|
break;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
331
|
+
// `file` is no longer a kind of event — an attachment is a property any
|
|
332
|
+
// card may have. The word is kept as an alias so senders that still say
|
|
333
|
+
// `notify file` deliver a report card with the log attached, instead of
|
|
334
|
+
// falling into the unknown-type branch and going silent.
|
|
335
|
+
case 'file':
|
|
297
336
|
event = {
|
|
298
|
-
type: '
|
|
337
|
+
type: 'report',
|
|
299
338
|
project: project(),
|
|
300
339
|
title: one('title') ?? '(no title)',
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
note: one('note')
|
|
340
|
+
period: one('note'),
|
|
341
|
+
lines: []
|
|
304
342
|
};
|
|
305
343
|
break;
|
|
306
|
-
}
|
|
307
344
|
default:
|
|
308
345
|
// В parseErrors, а не просто в лог: иначе неизвестный тип уходил в
|
|
309
346
|
// тишину — событие не собиралось, ошибок разбора не было, и CLI выходил
|
|
@@ -316,6 +353,12 @@ else {
|
|
|
316
353
|
// key в самом объекте, отсутствие флага не должно его затирать.
|
|
317
354
|
if (event) {
|
|
318
355
|
event.key = one('key') ?? event.key;
|
|
356
|
+
// --path is applicable to any type too, for the same reason --key is.
|
|
357
|
+
event.path = one('path') ?? event.path;
|
|
358
|
+
event.filename = one('filename') ?? event.filename;
|
|
359
|
+
}
|
|
360
|
+
if (event?.filename && !event.path) {
|
|
361
|
+
parseErrors.push('--filename: given without --path, so there is no file to name');
|
|
319
362
|
}
|
|
320
363
|
// Ошибки разбора — до отправки: лучше внятно сказать, что не так с командой,
|
|
321
364
|
// чем прислать сообщение с «true» вместо ссылки или 🔴 на успешном деплое.
|
package/dist/events.d.ts
CHANGED
|
@@ -22,8 +22,18 @@ export type Project = 'playhub' | 'one-q' | 'arvent' | 'game-publisher' | 'vault
|
|
|
22
22
|
* потоком по словам `sent|failed|skipped`) ключ не попадает никогда — новое
|
|
23
23
|
* слово там ослепило бы сторожа.
|
|
24
24
|
*/
|
|
25
|
+
/**
|
|
26
|
+
* Общая часть любого события. `path` — локальный файл, который едет ВМЕСТЕ с
|
|
27
|
+
* карточкой: карточка становится подписью к вложению. Отдельного вида `file`
|
|
28
|
+
* нет с 25.08.2026 — прогон Arvent слал вердикт и лог двумя карточками про
|
|
29
|
+
* одну новость.
|
|
30
|
+
*/
|
|
25
31
|
type Keyed = {
|
|
26
32
|
key?: string;
|
|
33
|
+
/** Локальный файл; карточка уедет как подпись к нему (лимит подписи 1024). */
|
|
34
|
+
path?: string;
|
|
35
|
+
/** Имя файла в чате; по умолчанию — имя из `path`. */
|
|
36
|
+
filename?: string;
|
|
27
37
|
};
|
|
28
38
|
/**
|
|
29
39
|
* Позиция списка внутри сообщения: задача из дайджеста, упавшая проверка,
|
|
@@ -78,12 +88,56 @@ export type NotifyEvent = Keyed & (
|
|
|
78
88
|
type: 'job';
|
|
79
89
|
project: Project;
|
|
80
90
|
job: string;
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
91
|
+
/**
|
|
92
|
+
* `disabled` — задача выключена извне (например GitHub Actions кончил
|
|
93
|
+
* бесплатные минуты), не провалилась сама.
|
|
94
|
+
*
|
|
95
|
+
* `silent` — задача не отчиталась в срок: она не упала, она вообще не
|
|
96
|
+
* подала признаков жизни. Это состояние ЗАДАЧИ, а не отдельный вид
|
|
97
|
+
* события — оно жило типом `heartbeat_miss`, и владелец справедливо
|
|
98
|
+
* спросил, почему задача по расписанию у него под двумя разными тегами.
|
|
99
|
+
* Хуже того: сторож молчания шлёт тот же машинный ключ, что и сама
|
|
100
|
+
* задача, так что красная карточка `#heartbeat #daily_import` не
|
|
101
|
+
* закрывалась зелёной `#job #daily_import` — разборщик ищет пару по
|
|
102
|
+
* ПОЛНОМУ тегу. Один поток на задачу это чинит.
|
|
103
|
+
*/
|
|
104
|
+
status: 'ok' | 'fail' | 'disabled' | 'silent';
|
|
105
|
+
/** Как часто задача обязана отмечаться — для `silent` и для возврата из него. */
|
|
106
|
+
expected?: string;
|
|
107
|
+
/** Когда её видели в последний раз. */
|
|
108
|
+
lastSeen?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Цифры от отправителя. Третий элемент — ИМЯ ГРУППЫ, под которой строка
|
|
111
|
+
* встанет. Владелец шесть раз просил группы, и каждый раз отправитель
|
|
112
|
+
* уже пытался их изобразить подручным: скобками в ярлыке
|
|
113
|
+
* («GA4 users (sum of days)»), значком в начале строки (🆕 против ⚠),
|
|
114
|
+
* лишней строкой внизу. Группировать было нечем — теперь есть.
|
|
115
|
+
*
|
|
116
|
+
* Без третьего элемента строка идёт без заголовка, как раньше: все
|
|
117
|
+
* существующие отправители продолжают работать не меняясь.
|
|
118
|
+
*/
|
|
119
|
+
stats?: Array<[label: string, value: string | number, group?: string]>;
|
|
84
120
|
/** Детали: что именно упало, замечания прогона; у `disabled` — список выключенных процессов (каждый со своей ссылкой). */
|
|
85
121
|
items?: Item[];
|
|
86
122
|
note?: string;
|
|
123
|
+
/**
|
|
124
|
+
* A command for him to run, rendered monospaced so Telegram makes it
|
|
125
|
+
* tap-to-copy. For the case where the card names something on this Mac
|
|
126
|
+
* that no URL can reach — a stopped local session, a latch file.
|
|
127
|
+
*/
|
|
128
|
+
command?: string;
|
|
129
|
+
/**
|
|
130
|
+
* WHAT that command does. The owner, on a bare `rm` in a card: "я сейчас
|
|
131
|
+
* введу её и сделаю хуй пойми что, я ж не знаю, что делаю". A command he
|
|
132
|
+
* cannot read is one he cannot run, so it never travels alone.
|
|
133
|
+
*/
|
|
134
|
+
commandNote?: string;
|
|
135
|
+
/**
|
|
136
|
+
* A local log path — monospaced, not a link, same as on an incident.
|
|
137
|
+
* It used to be glued onto the end of the reason sentence behind a
|
|
138
|
+
* colon, which is what made a red card read as one long run-on line.
|
|
139
|
+
*/
|
|
140
|
+
logs?: string;
|
|
87
141
|
workflowUrl?: string;
|
|
88
142
|
/** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
|
|
89
143
|
workflowName?: string;
|
|
@@ -101,7 +155,7 @@ export type NotifyEvent = Keyed & (
|
|
|
101
155
|
title: string;
|
|
102
156
|
period?: string;
|
|
103
157
|
/** Пусто/не передано, когда используются `groups` — два вида отчёта не смешиваются в одном событии. */
|
|
104
|
-
lines?: Array<[label: string, value: string | number]>;
|
|
158
|
+
lines?: Array<[label: string, value: string | number, group?: string]>;
|
|
105
159
|
/**
|
|
106
160
|
* Список позиций со ссылками — для дайджестов задач, где ценность в
|
|
107
161
|
* самих названиях, а не в цифре. Рендерятся отдельным блоком после
|
|
@@ -153,7 +207,7 @@ export type NotifyEvent = Keyed & (
|
|
|
153
207
|
| {
|
|
154
208
|
type: 'pr';
|
|
155
209
|
project: Project;
|
|
156
|
-
action: 'opened' | '
|
|
210
|
+
action: 'opened' | 'approved' | 'changes_requested' | 'merged' | 'closed';
|
|
157
211
|
number: number;
|
|
158
212
|
title: string;
|
|
159
213
|
/** PR description — quoted on its own; the title is the `Title:` field above it. */
|
|
@@ -185,7 +239,47 @@ export type NotifyEvent = Keyed & (
|
|
|
185
239
|
logs?: string;
|
|
186
240
|
url?: string;
|
|
187
241
|
}
|
|
188
|
-
/**
|
|
242
|
+
/**
|
|
243
|
+
* A working session on this Mac is in trouble — not a job, not a workflow.
|
|
244
|
+
* It went out as `job` at first and read wrong: `#job` promises something
|
|
245
|
+
* scheduled that ran and failed, and the owner rightly asked what a burning
|
|
246
|
+
* session was doing under that heading.
|
|
247
|
+
*
|
|
248
|
+
* What makes it its own type rather than an `incident`: a session has an
|
|
249
|
+
* identity nothing else here has — an id, a working directory, and the line
|
|
250
|
+
* he typed to start it, which is the ONLY thing that tells two of his open
|
|
251
|
+
* sessions apart.
|
|
252
|
+
*/
|
|
253
|
+
| {
|
|
254
|
+
type: 'session';
|
|
255
|
+
project: Project;
|
|
256
|
+
/** What happened, as the second line reads it: `Session: burning the limit`. */
|
|
257
|
+
action: string;
|
|
258
|
+
/** The session's own id — the identifier field, first, as everywhere else. */
|
|
259
|
+
id?: string;
|
|
260
|
+
/** Working directory name, when several sessions opened with a similar line. */
|
|
261
|
+
workdir?: string;
|
|
262
|
+
/** One line of measurement: what the guard saw. */
|
|
263
|
+
reason?: string;
|
|
264
|
+
/**
|
|
265
|
+
* The line he opened the session with. Quoted, never a field: it is his
|
|
266
|
+
* own writing, it runs long, and a field would clip it to one short line
|
|
267
|
+
* — which is exactly how the first version of this card lost it.
|
|
268
|
+
*/
|
|
269
|
+
opened?: string;
|
|
270
|
+
/** A command for him to run, monospaced so Telegram makes it copyable. */
|
|
271
|
+
command?: string;
|
|
272
|
+
/** WHAT that command does — see the note on `job.commandNote`. */
|
|
273
|
+
commandNote?: string;
|
|
274
|
+
/** `fail` red, `ok` green — a session that recovered is not an alarm. */
|
|
275
|
+
status?: 'fail' | 'ok';
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* УСТАРЕЛО с 1.4.2: используйте `job` со статусом `silent`. Тип остаётся,
|
|
279
|
+
* потому что сторож молчания живёт на сервере и до выкатки шлёт именно его —
|
|
280
|
+
* убрать значит потерять карточку молчания ровно тогда, когда она нужна.
|
|
281
|
+
* Новых вызовов не добавлять.
|
|
282
|
+
*/
|
|
189
283
|
| {
|
|
190
284
|
type: 'heartbeat_miss';
|
|
191
285
|
project: Project;
|
|
@@ -196,26 +290,47 @@ export type NotifyEvent = Keyed & (
|
|
|
196
290
|
recovered?: boolean;
|
|
197
291
|
/** Готовое предложение-причина; без него собирается из lastSeen/expected. */
|
|
198
292
|
note?: string;
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* Файл-вложение (sendDocument) с подписью-карточкой. Появился, когда
|
|
202
|
-
* eval-отчёт Arvent слал файл голым curl мимо пакета: без ретраев файл
|
|
203
|
-
* терялся в любую сетевую икоту, а chat_id и номер вкладки жили копией,
|
|
204
|
-
* которая устаревает молча. Маршрут — тот же `ROUTES`, транспорт — с теми
|
|
205
|
-
* же повторами. Подпись у Telegram ограничена 1024 символами — режется
|
|
206
|
-
* тем же безопасным клампом.
|
|
207
|
-
*/
|
|
208
|
-
| {
|
|
209
|
-
type: 'file';
|
|
210
|
-
project: Project;
|
|
211
|
-
title: string;
|
|
212
|
-
/** Путь к локальному файлу. */
|
|
213
|
-
path: string;
|
|
214
|
-
/** Имя файла в чате; по умолчанию — имя из `path`. */
|
|
215
|
-
filename?: string;
|
|
216
|
-
note?: string;
|
|
217
293
|
});
|
|
218
294
|
export type EventType = NotifyEvent['type'];
|
|
219
295
|
/** Красное = со звуком. Всё остальное — тихо. (Отдельной темы «инциденты» больше нет — авария видна в ленте проекта.) */
|
|
296
|
+
/**
|
|
297
|
+
* Словарь значков. Два закона, и оба поставлены владельцем 25.08.2026:
|
|
298
|
+
*
|
|
299
|
+
* 1. ВНУТРИ одного тега у каждого слова свой значок. Раньше значков было
|
|
300
|
+
* ровно четыре на весь пакет, и `Issue: opened` с `Issue: assigned`
|
|
301
|
+
* выглядели одинаково, а у задачи три разных беды — fail, disabled,
|
|
302
|
+
* silent — были одним и тем же красным кругом.
|
|
303
|
+
* 2. МЕЖДУ тегами одинаковый смысл выглядит одинаково. `fail` — это 🔴 и в
|
|
304
|
+
* выкатке, и в CI, и в задаче; «появилось новое» — 🆕 и у задачи на доске,
|
|
305
|
+
* и у PR, и у файла.
|
|
306
|
+
*
|
|
307
|
+
* И третий, который держит первые два честными: у значка фиксированный звук.
|
|
308
|
+
* Не у события, не у статуса — у значка. Пока звук выводился отдельным
|
|
309
|
+
* правилом, `🔴 PR: changes_requested` приходила беззвучно.
|
|
310
|
+
*/
|
|
311
|
+
export declare const ICON: {
|
|
312
|
+
readonly ok: "✅";
|
|
313
|
+
readonly red: "🔴";
|
|
314
|
+
readonly alarm: "🚨";
|
|
315
|
+
readonly off: "🚫";
|
|
316
|
+
readonly unknown: "❓";
|
|
317
|
+
readonly fresh: "🆕";
|
|
318
|
+
readonly taken: "🙋";
|
|
319
|
+
readonly landed: "🎉";
|
|
320
|
+
readonly discarded: "🗑️";
|
|
321
|
+
readonly approved: "👍";
|
|
322
|
+
readonly changes: "📝";
|
|
323
|
+
readonly info: "ℹ️";
|
|
324
|
+
};
|
|
325
|
+
/** The sound is a property of the icon, and of nothing else. */
|
|
326
|
+
export declare const LOUD: ReadonlySet<string>;
|
|
327
|
+
export declare const PR_ICON: Record<Extract<NotifyEvent, {
|
|
328
|
+
type: 'pr';
|
|
329
|
+
}>['action'], string>;
|
|
330
|
+
export declare const ISSUE_ICON: Record<Extract<NotifyEvent, {
|
|
331
|
+
type: 'issue';
|
|
332
|
+
}>['action'], string>;
|
|
333
|
+
/** Одно место, где решается значок карточки, — и рендер, и звук берут его отсюда. */
|
|
334
|
+
export declare const iconFor: (e: NotifyEvent) => string;
|
|
220
335
|
export declare const severity: (e: NotifyEvent) => "info" | "error";
|
|
221
336
|
export {};
|
package/dist/events.js
CHANGED
|
@@ -10,19 +10,82 @@
|
|
|
10
10
|
* Тогда старый вызывающий код и новый пакет совместимы в обе стороны.
|
|
11
11
|
*/
|
|
12
12
|
/** Красное = со звуком. Всё остальное — тихо. (Отдельной темы «инциденты» больше нет — авария видна в ленте проекта.) */
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Словарь значков. Два закона, и оба поставлены владельцем 25.08.2026:
|
|
15
|
+
*
|
|
16
|
+
* 1. ВНУТРИ одного тега у каждого слова свой значок. Раньше значков было
|
|
17
|
+
* ровно четыре на весь пакет, и `Issue: opened` с `Issue: assigned`
|
|
18
|
+
* выглядели одинаково, а у задачи три разных беды — fail, disabled,
|
|
19
|
+
* silent — были одним и тем же красным кругом.
|
|
20
|
+
* 2. МЕЖДУ тегами одинаковый смысл выглядит одинаково. `fail` — это 🔴 и в
|
|
21
|
+
* выкатке, и в CI, и в задаче; «появилось новое» — 🆕 и у задачи на доске,
|
|
22
|
+
* и у PR, и у файла.
|
|
23
|
+
*
|
|
24
|
+
* И третий, который держит первые два честными: у значка фиксированный звук.
|
|
25
|
+
* Не у события, не у статуса — у значка. Пока звук выводился отдельным
|
|
26
|
+
* правилом, `🔴 PR: changes_requested` приходила беззвучно.
|
|
27
|
+
*/
|
|
28
|
+
export const ICON = {
|
|
29
|
+
ok: '✅', // passed, closed, done
|
|
30
|
+
red: '🔴', // broken
|
|
31
|
+
alarm: '🚨', // burning right now
|
|
32
|
+
off: '🚫', // switched off — it will not run until someone turns it back on
|
|
33
|
+
unknown: '❓', // did not report: alive or dead is unknown
|
|
34
|
+
fresh: '🆕', // something new appeared
|
|
35
|
+
taken: '🙋', // someone took it
|
|
36
|
+
landed: '🎉', // merged — the work is in
|
|
37
|
+
discarded: '🗑️', // closed without reaching the result
|
|
38
|
+
approved: '👍', // a human approved it
|
|
39
|
+
changes: '📝', // a human wants edits — not a failure, and not loud
|
|
40
|
+
info: 'ℹ️' // a summary, for information
|
|
41
|
+
};
|
|
42
|
+
/** The sound is a property of the icon, and of nothing else. */
|
|
43
|
+
export const LOUD = new Set([ICON.red, ICON.alarm, ICON.off, ICON.unknown]);
|
|
44
|
+
export const PR_ICON = {
|
|
45
|
+
opened: ICON.fresh,
|
|
46
|
+
approved: ICON.approved,
|
|
47
|
+
changes_requested: ICON.changes,
|
|
48
|
+
merged: ICON.landed,
|
|
49
|
+
closed: ICON.discarded
|
|
50
|
+
};
|
|
51
|
+
export const ISSUE_ICON = {
|
|
52
|
+
opened: ICON.fresh,
|
|
53
|
+
assigned: ICON.taken,
|
|
54
|
+
closed: ICON.ok
|
|
55
|
+
};
|
|
56
|
+
const JOB_ICON = {
|
|
57
|
+
ok: ICON.ok,
|
|
58
|
+
fail: ICON.red,
|
|
59
|
+
disabled: ICON.off,
|
|
60
|
+
silent: ICON.unknown
|
|
61
|
+
};
|
|
62
|
+
/** Одно место, где решается значок карточки, — и рендер, и звук берут его отсюда. */
|
|
63
|
+
export const iconFor = (e) => {
|
|
64
|
+
switch (e.type) {
|
|
65
|
+
case 'deploy':
|
|
66
|
+
case 'ci':
|
|
67
|
+
return e.status === 'ok' ? ICON.ok : ICON.red;
|
|
68
|
+
case 'job':
|
|
69
|
+
return JOB_ICON[e.status];
|
|
70
|
+
case 'session':
|
|
71
|
+
return e.status === 'ok' ? ICON.ok : ICON.alarm;
|
|
72
|
+
case 'incident':
|
|
73
|
+
return ICON.alarm;
|
|
74
|
+
case 'heartbeat_miss':
|
|
75
|
+
return e.recovered ? ICON.ok : ICON.unknown;
|
|
76
|
+
case 'pr':
|
|
77
|
+
return PR_ICON[e.action];
|
|
78
|
+
case 'issue':
|
|
79
|
+
return ISSUE_ICON[e.action];
|
|
80
|
+
case 'report':
|
|
81
|
+
return ICON.info;
|
|
26
82
|
}
|
|
27
|
-
|
|
83
|
+
};
|
|
84
|
+
export const severity = (e) => {
|
|
85
|
+
// ONE law: the icon decides the sound. There is no second list of "which
|
|
86
|
+
// events are bad" to keep in sync with the icons — keeping two lists is how
|
|
87
|
+
// `🔴 PR: changes_requested` ended up arriving MUTED, the only red card in
|
|
88
|
+
// the package that did not ring, because severity() looked at `status` and a
|
|
89
|
+
// pull request has an `action`.
|
|
90
|
+
return LOUD.has(iconFor(e)) ? 'error' : 'info';
|
|
28
91
|
};
|
package/dist/index.d.ts
CHANGED
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 { ICON, LOUD, 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, '&')
|
|
@@ -118,6 +149,23 @@ const fieldLink = (label, url, text) => {
|
|
|
118
149
|
const fieldAction = (label, url, text) => url ? `<b>${esc(cap(label))}:</b> <a href="${esc(url)}">${esc(text ?? 'open')}</a>` : null;
|
|
119
150
|
/** Моноширинное поле — путь/команда для копирования, не ссылка. */
|
|
120
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
|
+
};
|
|
121
169
|
/** Заголовок группы: курсив + подчёркивание, без жирности, без двоеточия. */
|
|
122
170
|
const group = (name) => `<i><u>${esc(cap(name))}</u></i>`;
|
|
123
171
|
/** Позиция внутри группы: `<b>label:</b> <a>text</a>` — либо простая маркированная/нумерованная строка без label. */
|
|
@@ -140,7 +188,36 @@ const note = (text) => {
|
|
|
140
188
|
const body = esc(text);
|
|
141
189
|
return body.length > EXPAND_AT ? `<blockquote expandable>${body}</blockquote>` : `<blockquote>${body}</blockquote>`;
|
|
142
190
|
};
|
|
143
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Цитата с подписью. Голая цитата читается как продолжение поля над ней:
|
|
193
|
+
* владелец спросил про строку, которой открыта сессия, «что значит этот текст,
|
|
194
|
+
* откуда он берётся» — и был прав, в карточке это нигде не сказано. Подпись
|
|
195
|
+
* стоит отдельной строкой, потому что сам текст в поле не помещается: поле
|
|
196
|
+
* держит одну строку и обрезает.
|
|
197
|
+
*/
|
|
198
|
+
const quoted = (label, text) => text ? `<b>${esc(cap(label))}</b>\n${note(text)}` : null;
|
|
199
|
+
/**
|
|
200
|
+
* Склейка карточки. Пустая строка здесь — знак смены блока, а не отступ:
|
|
201
|
+
* две подряд означают пустой блок, ведущая — блок, которого нет. Обе
|
|
202
|
+
* появляются, когда часть полей не пришла, и обе схлопываются тут, а не
|
|
203
|
+
* в каждом рендерере по отдельности.
|
|
204
|
+
*/
|
|
205
|
+
const join = (parts) => {
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const part of parts) {
|
|
208
|
+
if (part === null) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (part === '' && (out.length === 0 || out[out.length - 1] === '')) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
out.push(part);
|
|
215
|
+
}
|
|
216
|
+
while (out.length > 0 && out[out.length - 1] === '') {
|
|
217
|
+
out.pop();
|
|
218
|
+
}
|
|
219
|
+
return out.join('\n');
|
|
220
|
+
};
|
|
144
221
|
/** Плоский список позиций (без ярлыков) — job/report без групп. */
|
|
145
222
|
const bullets = (items, numbered) => (items ?? []).map((it, i) => groupItem(it, i, numbered));
|
|
146
223
|
/** Именованная группа целиком: заголовок + позиции, разделены строкой пустоты внутри вызова через join. */
|
|
@@ -163,19 +240,94 @@ const renderGroup = (g) => [
|
|
|
163
240
|
*/
|
|
164
241
|
const titleField = (title) => field('Title', title);
|
|
165
242
|
const bodyQuote = (body) => body ? note(body) : null;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
243
|
+
/**
|
|
244
|
+
* Строки с ярлыками, разложенные по группам, которые назвал сам отправитель.
|
|
245
|
+
*
|
|
246
|
+
* Закон простой и считается программой: назвал группу — заголовок печатается.
|
|
247
|
+
* Всегда, сколько бы строк в ней ни было и сколько бы групп ни оказалось.
|
|
248
|
+
* Порог «две и больше» я пробовал и снял: у карточки резервных копий все
|
|
249
|
+
* цифры лежат в одной группе, а над ними — рассказ о прогоне, и порог гасил
|
|
250
|
+
* ровно тот шов, ради которого владелец всё это и просил.
|
|
251
|
+
*
|
|
252
|
+
* Так же уходит и риск «одна и та же карточка выглядит по-разному в разные
|
|
253
|
+
* дни»: вид зависит от того, что отправитель НАЗВАЛ в коде, а не от того,
|
|
254
|
+
* сколько строк набралось сегодня.
|
|
255
|
+
*
|
|
256
|
+
* Порядок групп — порядок первого появления у отправителя: он знает, что
|
|
257
|
+
* важнее. Строки без имени идут первыми и без заголовка — это факты о самой
|
|
258
|
+
* карточке, а не о каком-то из её предметов.
|
|
259
|
+
*/
|
|
260
|
+
const labelled = (rows) => {
|
|
261
|
+
const list = rows ?? [];
|
|
262
|
+
const names = [...new Set(list.map(([, , g]) => g).filter((g) => !!g))];
|
|
263
|
+
if (names.length === 0) {
|
|
264
|
+
return list.map(([label, value]) => field(label, value)).filter((l) => l !== null);
|
|
265
|
+
}
|
|
266
|
+
const out = [];
|
|
267
|
+
const bare = list.filter(([, , g]) => !g);
|
|
268
|
+
for (const [label, value] of bare) {
|
|
269
|
+
const line = field(label, value);
|
|
270
|
+
if (line !== null) {
|
|
271
|
+
out.push(line);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const name of names) {
|
|
275
|
+
// Пустая строка перед КАЖДЫМ заголовком, включая первый: над ним всегда
|
|
276
|
+
// стоят поля самой карточки (Task, Period), и без шва заголовок читался
|
|
277
|
+
// как ещё одна их строка. Двойных пустот бояться не нужно — их схлопывает
|
|
278
|
+
// `join`.
|
|
279
|
+
out.push('');
|
|
280
|
+
out.push(group(name));
|
|
281
|
+
for (const [label, value] of list.filter(([, , g]) => g === name)) {
|
|
282
|
+
const line = field(label, value);
|
|
283
|
+
if (line !== null) {
|
|
284
|
+
out.push(line);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* Блоки, которыми владеет сам рендерер, — у выкатки и проверки их два, и они
|
|
292
|
+
* про разные вещи: `Run` это сам прогон и его обстоятельства, `Change` это
|
|
293
|
+
* изменение, из-за которого он случился. Владелец на CI-карточке: «commit,
|
|
294
|
+
* actor, workflow — не знаю, всё так сумбурно».
|
|
295
|
+
*
|
|
296
|
+
* Заголовок печатается у КАЖДОГО непустого блока, а не только когда их два.
|
|
297
|
+
* Сначала было «два и больше», ради экономии строки на зелёной карточке, и
|
|
298
|
+
* это оказалось ошибкой: у зелёной выкатки нет ни цели, ни причины, блок один,
|
|
299
|
+
* заголовки пропадали — и один и тот же вид уведомления выглядел в разные дни
|
|
300
|
+
* по-разному. Владелец дважды спросил «почему здесь нет групп», глядя именно
|
|
301
|
+
* на зелёную. Строка заголовка стоит дешевле, чем необходимость каждый раз
|
|
302
|
+
* заново искать глазами, где что.
|
|
303
|
+
*/
|
|
304
|
+
const twoBlocks = (run, change) => {
|
|
305
|
+
const live = (rows) => rows.filter((r) => r !== null && r !== '');
|
|
306
|
+
const out = [];
|
|
307
|
+
for (const [name, rows] of [['Run', live(run)], ['Change', live(change)]]) {
|
|
308
|
+
if (rows.length > 0) {
|
|
309
|
+
out.push('', group(name), ...rows);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
};
|
|
314
|
+
// Значок и его закон живут в events.ts: от него зависит и звук.
|
|
170
315
|
/** Строка 2: значок вне жирного, `<b>Тип:</b> действие` — то же поле, не особый случай. */
|
|
171
316
|
// `action` объявлен строкой, но приходит и из `--json`, и из прямых вызовов на
|
|
172
317
|
// JS, где типов нет. Пустое или отсутствующее значение давало строку `ℹ️ null`
|
|
173
318
|
// прямо во второй строке карточки. Пустая строка честнее: поле просто исчезает.
|
|
174
|
-
|
|
319
|
+
// A link belongs on the NAME of the thing it opens, never on a separate row
|
|
320
|
+
// whose only text is the verb `open`. The owner read `Details: open` under a
|
|
321
|
+
// report and asked what "open" was — the answer is the report itself, which was
|
|
322
|
+
// sitting three lines above as dead text. So line 2 takes an optional URL and
|
|
323
|
+
// the action text becomes the link: `Report: <a>Analytics for 12.08</a>`.
|
|
324
|
+
const typeLine = (icon, type, action, url) => {
|
|
175
325
|
// `field` возвращает null на пустом значении, а интерполяция null в шаблон
|
|
176
326
|
// печатает слово «null». Так вторая строка карточки становилась `ℹ️ null` —
|
|
177
327
|
// достижимо через `--json` и прямой вызов на JS, где типов нет.
|
|
178
|
-
|
|
328
|
+
// `action || 'open'` in the linked case: an empty title must not swallow the
|
|
329
|
+
// link, which would be the one thing the card cannot afford to lose.
|
|
330
|
+
const line = url ? fieldLink(type, url, action || 'open') : field(type, action);
|
|
179
331
|
return line === null ? `${icon} <b>${esc(cap(type))}</b>` : `${icon} ${line}`;
|
|
180
332
|
};
|
|
181
333
|
// `workflowUrl ?? url`: половина отправителей шлёт ссылку на прогон под именем
|
|
@@ -183,23 +335,40 @@ const typeLine = (icon, type, action) => {
|
|
|
183
335
|
// только `workflowUrl`, поэтому красная карточка приходила БЕЗ ЕДИНОЙ ССЫЛКИ
|
|
184
336
|
// на логи. Отвергать `--url` было бы честнее по имени и хуже по делу: намерение
|
|
185
337
|
// однозначно, а карточка без ссылки бесполезна ровно тогда, когда нужна.
|
|
338
|
+
/**
|
|
339
|
+
* What to call the thing that ran. The workflow's own name first — it is the
|
|
340
|
+
* only text here that identifies THIS run. Then the caller's own word for the
|
|
341
|
+
* mechanism (`manual, from the Mac`). Last resort `the run`, and only when a
|
|
342
|
+
* link exists: losing the link to the logs on a red card is the one loss this
|
|
343
|
+
* format cannot afford, and a row that says nothing is still better than a
|
|
344
|
+
* card with nowhere to click. No live sender reaches that last resort — the
|
|
345
|
+
* GitHub Action always fills the workflow name, and the hand-run scripts send
|
|
346
|
+
* no run link at all.
|
|
347
|
+
*/
|
|
348
|
+
const mechanism = (workflowName, via, runUrl) => workflowName ?? via ?? (runUrl ? 'the run' : undefined);
|
|
349
|
+
// The name of what ran sits WITH the type line, not eight lines below it.
|
|
350
|
+
// `Deploy: fail` and `by what means it ran` answer one question, and the owner
|
|
351
|
+
// read the two rows as unrelated things. It used to be one fact split in two:
|
|
352
|
+
// `Via: GitHub Actions` in the middle of the card and a trailing
|
|
353
|
+
// `Workflow: <run>` in the actions block. On one-q that trailing row rendered
|
|
354
|
+
// `Workflow: Deploy` — the link text repeating the word on line 2 and naming
|
|
355
|
+
// nothing.
|
|
356
|
+
//
|
|
357
|
+
// The link text is the workflow's OWN name, never the platform: `GitHub
|
|
358
|
+
// Actions` is identical on every card in every repository, so clicking it told
|
|
359
|
+
// the owner nothing about where he was going. `manual, from the Mac` stays
|
|
360
|
+
// unlinked, because a hand deploy has no run to open.
|
|
186
361
|
const renderDeploy = (e) => {
|
|
187
|
-
const icon = e
|
|
362
|
+
const icon = iconFor(e);
|
|
363
|
+
const runUrl = e.workflowUrl ?? e.url;
|
|
188
364
|
return join([
|
|
189
365
|
typeLine(icon, 'Deploy', e.status),
|
|
190
|
-
'',
|
|
191
|
-
fieldLink('Commit', e.commitUrl, e.commit),
|
|
192
|
-
titleField(e.commitTitle),
|
|
193
|
-
bodyQuote(e.commitBody),
|
|
194
|
-
field('Via', e.via),
|
|
195
|
-
field('Target', e.target),
|
|
196
|
-
field('Reason', e.note),
|
|
197
|
-
e.workflowUrl ?? e.url ? '' : null,
|
|
198
|
-
fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
|
|
366
|
+
fieldLink('Via', runUrl, mechanism(e.workflowName, e.via, runUrl)),
|
|
367
|
+
...twoBlocks([field('Target', e.target), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
|
|
199
368
|
]);
|
|
200
369
|
};
|
|
201
370
|
const renderJob = (e) => {
|
|
202
|
-
const icon = e
|
|
371
|
+
const icon = iconFor(e);
|
|
203
372
|
const hasItems = (e.items ?? []).length > 0;
|
|
204
373
|
const disabledList = hasItems && e.status === 'disabled';
|
|
205
374
|
return join([
|
|
@@ -210,77 +379,79 @@ const renderJob = (e) => {
|
|
|
210
379
|
// repeating the label of the line right above it reads as a mistake.
|
|
211
380
|
// Until now the name was dropped entirely — every caller passed it and the
|
|
212
381
|
// owner only ever saw it as the small grey instance tag.
|
|
213
|
-
|
|
382
|
+
// The run link rides on the task's own name. It used to sit at the bottom
|
|
383
|
+
// as `Workflow: open` — every job caller passes a URL and none passes a
|
|
384
|
+
// workflow name, so that row was the bare verb the owner objected to.
|
|
385
|
+
fieldLink('Task', e.workflowUrl ?? e.url, e.job),
|
|
214
386
|
field('Reason', e.note),
|
|
215
|
-
|
|
387
|
+
field('Expected', e.expected),
|
|
388
|
+
// `Last run` when the task is alive, `Last seen` when it is not: the same
|
|
389
|
+
// timestamp answers two different questions.
|
|
390
|
+
field(e.status === 'silent' ? 'Last seen' : 'Last run', e.lastSeen),
|
|
391
|
+
...labelled(e.stats),
|
|
216
392
|
hasItems ? '' : null,
|
|
217
393
|
// Heading ONLY for `disabled`. It used to print for any job carrying a
|
|
218
394
|
// list, so playhub's daily card of newly published games was headed
|
|
219
395
|
// "Disabled workflows".
|
|
220
396
|
disabledList ? group('Disabled workflows') : null,
|
|
221
397
|
...(hasItems ? bullets(e.items, disabledList) : []),
|
|
222
|
-
e.
|
|
223
|
-
|
|
398
|
+
e.command || e.logs ? '' : null,
|
|
399
|
+
fieldCode('Log', e.logs),
|
|
400
|
+
...fieldRun(e.command, e.commandNote),
|
|
401
|
+
// Kept only when the caller actually names the workflow — a named row is a
|
|
402
|
+
// second, different destination; an unnamed one repeats the Task link.
|
|
403
|
+
e.workflowName && (e.workflowUrl ?? e.url) ? '' : null,
|
|
404
|
+
e.workflowName ? fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName) : null
|
|
224
405
|
]);
|
|
225
406
|
};
|
|
226
407
|
const renderReport = (e) => {
|
|
227
408
|
if (e.groups && e.groups.length > 0) {
|
|
228
409
|
const body = e.groups.flatMap((g, i) => (i === 0 ? renderGroup(g) : ['', ...renderGroup(g)]));
|
|
410
|
+
// `lines` и `groups` вместе, а не «или»: раньше ветка с группами печатала
|
|
411
|
+
// ТОЛЬКО группы, и цифры отчёта молча исчезали. Поймано 25.08.2026 при
|
|
412
|
+
// переводе утреннего отчёта PlayHub на типизированное событие.
|
|
413
|
+
const numbers = labelled(e.lines);
|
|
229
414
|
return join([
|
|
230
|
-
typeLine(
|
|
415
|
+
typeLine(iconFor(e), 'Report', e.title, e.url),
|
|
416
|
+
// Период стоит ВПЛОТНУЮ к названию, без пустой строки, по тому же
|
|
417
|
+
// закону, что `Via` у выкатки и `Check` у проверки: строка, которая
|
|
418
|
+
// уточняет вторую строку, живёт рядом с ней, а не в блоке фактов.
|
|
419
|
+
// Владелец: «период пошёл не туда, он же должен быть рядом с датой».
|
|
420
|
+
field('Period', e.period),
|
|
231
421
|
'',
|
|
232
|
-
...
|
|
233
|
-
|
|
234
|
-
|
|
422
|
+
...numbers,
|
|
423
|
+
body.length > 0 ? '' : null,
|
|
424
|
+
...body
|
|
235
425
|
]);
|
|
236
426
|
}
|
|
237
427
|
const items = bullets(e.items, false);
|
|
238
428
|
return join([
|
|
239
|
-
|
|
429
|
+
// Both analytics jobs send a link to the day's snapshot in docs/. It used to
|
|
430
|
+
// hang off a trailing `Details: open` row; now it is the report's own name.
|
|
431
|
+
typeLine(iconFor(e), 'Report', e.title, e.url),
|
|
432
|
+
// Вплотную к названию — см. соседнюю ветку.
|
|
433
|
+
field('Period', e.period),
|
|
240
434
|
'',
|
|
241
|
-
...(e.lines
|
|
435
|
+
...labelled(e.lines),
|
|
242
436
|
items.length > 0 ? '' : null,
|
|
243
|
-
...items
|
|
244
|
-
// Обе аналитики шлют сюда ссылку на снимок дня в docs/. Рендер её не читал,
|
|
245
|
-
// и дневной отчёт приходил без единственного способа посмотреть подробности.
|
|
246
|
-
e.url ? '' : null,
|
|
247
|
-
fieldAction('Details', e.url, undefined)
|
|
437
|
+
...items
|
|
248
438
|
]);
|
|
249
439
|
};
|
|
440
|
+
// Same law as the deploy card, one row up: what ran is named beside the type
|
|
441
|
+
// line and carries the link to its run. The label is `Check` and not `Via`
|
|
442
|
+
// because here the name answers WHICH gate spoke — `nightly`, `Quality` —
|
|
443
|
+
// while on a deploy it answers by what means the code was shipped.
|
|
250
444
|
const renderCi = (e) => {
|
|
251
|
-
const icon = e
|
|
445
|
+
const icon = iconFor(e);
|
|
446
|
+
const runUrl = e.workflowUrl ?? e.url;
|
|
252
447
|
return join([
|
|
253
448
|
typeLine(icon, 'CI', e.status),
|
|
254
|
-
'',
|
|
255
|
-
fieldLink('Commit', e.commitUrl, e.commit),
|
|
256
|
-
titleField(e.commitTitle),
|
|
257
|
-
bodyQuote(e.commitBody),
|
|
258
|
-
field('Actor', e.actor),
|
|
259
|
-
field('Reason', e.note),
|
|
260
|
-
e.workflowUrl ?? e.url ? '' : null,
|
|
261
|
-
fieldAction('Workflow', e.workflowUrl ?? e.url, e.workflowName)
|
|
449
|
+
fieldLink('Check', runUrl, mechanism(e.workflowName, undefined, runUrl)),
|
|
450
|
+
...twoBlocks([field('Actor', e.actor), field('Reason', e.note)], [fieldLink('Commit', e.commitUrl, e.commit), titleField(e.commitTitle), bodyQuote(e.commitBody)])
|
|
262
451
|
]);
|
|
263
452
|
};
|
|
264
|
-
// PR/Issue: значок теперь по статусу (четыре на пакет), не по действию —
|
|
265
|
-
// `merged`/`approved` = успех, `changes_requested` = требует внимания,
|
|
266
|
-
// остальное = к сведению. Слово действия само по себе уже говорит, что
|
|
267
|
-
// произошло (`opened`, `ready_for_review` и т.д.), значок дублировать не должен.
|
|
268
|
-
const PR_ICON = {
|
|
269
|
-
opened: ICON.info,
|
|
270
|
-
ready_for_review: ICON.info,
|
|
271
|
-
review_requested: ICON.info,
|
|
272
|
-
approved: ICON.ok,
|
|
273
|
-
changes_requested: ICON.red,
|
|
274
|
-
merged: ICON.ok,
|
|
275
|
-
closed: ICON.info
|
|
276
|
-
};
|
|
277
|
-
const ISSUE_ICON = {
|
|
278
|
-
opened: ICON.info,
|
|
279
|
-
assigned: ICON.info,
|
|
280
|
-
closed: ICON.ok
|
|
281
|
-
};
|
|
282
453
|
const renderPr = (e) => join([
|
|
283
|
-
typeLine(
|
|
454
|
+
typeLine(iconFor(e), 'PR', e.action),
|
|
284
455
|
'',
|
|
285
456
|
// Идентификатор первым, заголовок под ним: так вещь читается «#118, вот
|
|
286
457
|
// такая», а не «вот такая, кстати #118» — и так её пишет сам GitHub.
|
|
@@ -294,7 +465,7 @@ const renderPr = (e) => join([
|
|
|
294
465
|
field('Reviewer', e.reviewer)
|
|
295
466
|
]);
|
|
296
467
|
const renderIssue = (e) => join([
|
|
297
|
-
typeLine(
|
|
468
|
+
typeLine(iconFor(e), 'Issue', e.action),
|
|
298
469
|
'',
|
|
299
470
|
fieldLink('Number', e.url, `#${e.number}`),
|
|
300
471
|
titleField(e.title),
|
|
@@ -303,7 +474,7 @@ const renderIssue = (e) => join([
|
|
|
303
474
|
field('Assignee', e.assignee)
|
|
304
475
|
]);
|
|
305
476
|
const renderIncident = (e) => join([
|
|
306
|
-
typeLine(
|
|
477
|
+
typeLine(iconFor(e), 'Incident', 'open'),
|
|
307
478
|
'',
|
|
308
479
|
// `detail` is a diagnosis of several lines (vault greps three of them plus a
|
|
309
480
|
// log path). It used to go through `field`, which keeps only the first line,
|
|
@@ -311,18 +482,33 @@ const renderIncident = (e) => join([
|
|
|
311
482
|
// commit now: short label, full text quoted under it.
|
|
312
483
|
// Ярлык `Title`, а не `Reason`: у аварии заголовок — такой же заголовок,
|
|
313
484
|
// как у коммита и задачи, и называться в одной карточке он должен так же.
|
|
314
|
-
|
|
485
|
+
// Same rule as the report: the link rides on the incident's own title
|
|
486
|
+
// rather than on a trailing row whose only text is the word `open`.
|
|
487
|
+
fieldLink('Title', e.url, e.title),
|
|
315
488
|
e.detail && e.detail !== e.title ? note(e.detail) : null,
|
|
316
|
-
e.logs
|
|
317
|
-
fieldCode('Logs', e.logs)
|
|
318
|
-
fieldAction('Workflow', e.url, undefined)
|
|
489
|
+
e.logs ? '' : null,
|
|
490
|
+
fieldCode('Logs', e.logs)
|
|
319
491
|
]);
|
|
320
492
|
// Раньше всё это склеивалось в одну строку `Reason:` через тире: «имя — no
|
|
321
493
|
// reports — expected X, last seen Y». Каждая другая карточка кладёт факт на
|
|
322
494
|
// свою строку с ярлыком, и владелец справедливо спросил, зачем тут отдельный
|
|
323
495
|
// формат. Отдельного формата больше нет.
|
|
496
|
+
// A session in trouble. Same law as every other card: identifier first, then
|
|
497
|
+
// the facts as fields, then his own words as a quote — never as a field, which
|
|
498
|
+
// keeps one line and clipped the name of the very session the card is about.
|
|
499
|
+
const renderSession = (e) => join([
|
|
500
|
+
typeLine(iconFor(e), 'Session', e.action),
|
|
501
|
+
'',
|
|
502
|
+
field('Id', e.id),
|
|
503
|
+
field('Project', e.workdir),
|
|
504
|
+
field('Reason', e.reason),
|
|
505
|
+
e.opened ? '' : null,
|
|
506
|
+
quoted('Opened with', e.opened),
|
|
507
|
+
e.command ? '' : null,
|
|
508
|
+
...fieldRun(e.command, e.commandNote)
|
|
509
|
+
]);
|
|
324
510
|
const renderHeartbeatMiss = (e) => {
|
|
325
|
-
const icon = e
|
|
511
|
+
const icon = iconFor(e);
|
|
326
512
|
const action = e.recovered ? 'ok' : 'miss';
|
|
327
513
|
return join([
|
|
328
514
|
typeLine(icon, 'Heartbeat', action),
|
|
@@ -333,16 +519,6 @@ const renderHeartbeatMiss = (e) => {
|
|
|
333
519
|
field(e.recovered ? 'Last run' : 'Last seen', e.lastSeen)
|
|
334
520
|
]);
|
|
335
521
|
};
|
|
336
|
-
// Подпись файла — та же карточка, но лимит Telegram у caption свой: 1024.
|
|
337
|
-
const renderFile = (e) => join([
|
|
338
|
-
typeLine(ICON.info, 'File', 'new'),
|
|
339
|
-
'',
|
|
340
|
-
// Раньше здесь стояло `field('Title', e.note ?? e.title)`: подпись файла
|
|
341
|
-
// приходила под ярлыком заголовка, а сам заголовок из карточки исчезал.
|
|
342
|
-
// Один ярлык — один смысл: Title это title, Reason это note.
|
|
343
|
-
field('Title', e.title),
|
|
344
|
-
field('Reason', e.note)
|
|
345
|
-
]);
|
|
346
522
|
const RENDERERS = {
|
|
347
523
|
deploy: renderDeploy,
|
|
348
524
|
job: renderJob,
|
|
@@ -351,8 +527,8 @@ const RENDERERS = {
|
|
|
351
527
|
pr: renderPr,
|
|
352
528
|
issue: renderIssue,
|
|
353
529
|
incident: renderIncident,
|
|
354
|
-
|
|
355
|
-
|
|
530
|
+
session: renderSession,
|
|
531
|
+
heartbeat_miss: renderHeartbeatMiss
|
|
356
532
|
};
|
|
357
533
|
// Тег наверху карточки И машинный ключ разборщика — ОДНО И ТО ЖЕ значение
|
|
358
534
|
// (решение владельца 20.08.2026): раньше это были два разных представления
|
|
@@ -361,7 +537,7 @@ const RENDERERS = {
|
|
|
361
537
|
// разрывает Telegram-хэштег на середине слова (`#mac-config` линкуется
|
|
362
538
|
// только как `#mac`), а тег ДОЛЖЕН быть кликабельным — это и есть фильтр
|
|
363
539
|
// «показать всю историю этого экземпляра», которым владелец пользуется вживую.
|
|
364
|
-
const slug = (raw) => raw
|
|
540
|
+
export const slug = (raw) => raw
|
|
365
541
|
.toLowerCase()
|
|
366
542
|
.replace(/[^\p{L}\p{N}]+/gu, '_')
|
|
367
543
|
.replace(/^_+|_+$/g, '')
|
|
@@ -377,8 +553,8 @@ const TYPE_TAG = {
|
|
|
377
553
|
pr: 'pr',
|
|
378
554
|
issue: 'issue',
|
|
379
555
|
incident: 'incident',
|
|
380
|
-
|
|
381
|
-
|
|
556
|
+
session: 'session',
|
|
557
|
+
heartbeat_miss: 'heartbeat'
|
|
382
558
|
};
|
|
383
559
|
/**
|
|
384
560
|
* Экземпляр-тег: что именно это конкретное событие (ветка, окружение,
|
|
@@ -400,8 +576,11 @@ export const eventKey = (e) => {
|
|
|
400
576
|
return slug(e.job);
|
|
401
577
|
case 'report':
|
|
402
578
|
case 'incident':
|
|
403
|
-
case 'file':
|
|
404
579
|
return slug(e.title);
|
|
580
|
+
// NOT the session id: an id is unique per session, so the tag would be
|
|
581
|
+
// new every time and nothing could ever be paired with anything.
|
|
582
|
+
case 'session':
|
|
583
|
+
return slug(e.action);
|
|
405
584
|
case 'pr':
|
|
406
585
|
return `p${e.number}`;
|
|
407
586
|
case 'issue':
|
|
@@ -410,7 +589,36 @@ export const eventKey = (e) => {
|
|
|
410
589
|
};
|
|
411
590
|
return e.key ? slug(e.key) : fallback();
|
|
412
591
|
};
|
|
413
|
-
|
|
592
|
+
/**
|
|
593
|
+
* Третий тег — ИСХОД, и он есть всегда. Владелец: «не хватает тега fail или
|
|
594
|
+
* похожего, чтобы фейлы можно было группировать и ок можно было группировать».
|
|
595
|
+
* Одно нажатие в Telegram собирает все падения проекта разом, каким бы типом
|
|
596
|
+
* они ни пришли — выкатка, проверка, задача по расписанию, авария.
|
|
597
|
+
*
|
|
598
|
+
* Значение берётся у ЗНАЧКА, а не у слова статуса, и это не мелочь: значок уже
|
|
599
|
+
* единственный источник правды про звук, и второй список «что считать
|
|
600
|
+
* падением» разошёлся бы с первым — так уже было, когда красная карточка
|
|
601
|
+
* приходила беззвучной. Громкий значок — `#fail`, зелёный — `#ok`, всё
|
|
602
|
+
* остальное (завели задачу, открыли PR, попросили правки, отчёт) — `#news`:
|
|
603
|
+
* это новость, а не приговор робота.
|
|
604
|
+
*/
|
|
605
|
+
const OK_ICONS = new Set([ICON.ok, ICON.landed, ICON.approved]);
|
|
606
|
+
const outcomeTag = (e) => {
|
|
607
|
+
const icon = iconFor(e);
|
|
608
|
+
if (LOUD.has(icon)) {
|
|
609
|
+
return 'fail';
|
|
610
|
+
}
|
|
611
|
+
return OK_ICONS.has(icon) ? 'ok' : 'news';
|
|
612
|
+
};
|
|
613
|
+
const tagsLine = (e) => `#${TYPE_TAG[e.type]} #${esc(eventKey(e))} #${outcomeTag(e)}`;
|
|
614
|
+
/**
|
|
615
|
+
* Строка тегов для свободного HTML (`sendReport`). Тег — это ФИЛЬТР владельца,
|
|
616
|
+
* и к формату тела он отношения не имеет: дневной отчёт остаётся свободным
|
|
617
|
+
* текстом, но перестаёт быть единственной карточкой без тегов. Раньше ключ
|
|
618
|
+
* висел хвостом в `<i><code>#ключ</code></i>` — это старый формат, до того как
|
|
619
|
+
* теги переехали первой строкой.
|
|
620
|
+
*/
|
|
621
|
+
export const reportTags = (key) => `#report #${esc(slug(key))}`;
|
|
414
622
|
/**
|
|
415
623
|
* Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
|
|
416
624
|
* Теги — ПЕРВАЯ строка, добавляются до обрезки (не после, как раньше): они
|
|
@@ -430,6 +638,7 @@ export const render = (e) => {
|
|
|
430
638
|
// clampMessage может выйти за переданный limit на хвост закрывающих тегов и
|
|
431
639
|
// многоточие — минус 40 оставляет ему этот запас. У сообщений свой запас уже
|
|
432
640
|
// есть (4000 против 4096 у Telegram), у caption лимит 1024 настоящий.
|
|
433
|
-
|
|
641
|
+
// Карточка с вложением — это подпись, поэтому бюджет выбирается по `path`.
|
|
642
|
+
const budget = Math.max(64, e.path ? 1024 - tags.length - 40 : 4000 - tags.length - 1);
|
|
434
643
|
return `${tags}\n${clampMessage(renderer(e), budget)}`;
|
|
435
644
|
};
|
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.
|
|
@@ -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
|
};
|
package/dist/setup.js
CHANGED
|
@@ -23,21 +23,21 @@ const createTopic = async (token, chat, name, color) => {
|
|
|
23
23
|
});
|
|
24
24
|
const body = (await res.json());
|
|
25
25
|
if (!body.ok || !body.result) {
|
|
26
|
-
log(
|
|
26
|
+
log(`could not create "${name}": ${body.description ?? `HTTP ${res.status}`}`);
|
|
27
27
|
return null;
|
|
28
28
|
}
|
|
29
29
|
return body.result.message_thread_id;
|
|
30
30
|
}
|
|
31
31
|
catch (err) {
|
|
32
32
|
// Сеть/таймаут/нечитаемый ответ — не роняем CLI (его контракт: всегда exit 0).
|
|
33
|
-
log(
|
|
33
|
+
log(`could not create "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
36
|
};
|
|
37
37
|
export const setupTopic = async (chatId, projectKey) => {
|
|
38
38
|
const token = process.env.OPS_BOT_TOKEN?.trim();
|
|
39
39
|
if (!token) {
|
|
40
|
-
log('
|
|
40
|
+
log('no OPS_BOT_TOKEN — cannot create the tabs');
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
43
|
const ops = await createTopic(token, chatId, '⚙️ Ops', 9367192);
|
|
@@ -45,16 +45,16 @@ export const setupTopic = async (chatId, projectKey) => {
|
|
|
45
45
|
// Частичный успех: если создался только Ops — печатаем его, иначе повторный
|
|
46
46
|
// запуск создал бы ДРУГУЮ тему Ops, а старый id потерялся бы.
|
|
47
47
|
if (ops === null) {
|
|
48
|
-
log('Ops
|
|
48
|
+
log('Ops was not created — check: is the bot a group admin with "Manage topics", and are topics on?');
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
51
51
|
if (dev === null) {
|
|
52
|
-
log(`Ops
|
|
53
|
-
log('
|
|
54
|
-
log(` ${JSON.stringify(projectKey)}: { chat: '${chatId}', ops: ${ops}, dev:
|
|
52
|
+
log(`Ops created (id=${ops}), Dev was not — add Dev by hand or run again, and keep ops=${ops}`);
|
|
53
|
+
log('add to src/routes.ts (fill dev in afterwards):');
|
|
54
|
+
log(` ${JSON.stringify(projectKey)}: { chat: '${chatId}', ops: ${ops}, dev: <fill in> },`);
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
|
-
log(
|
|
58
|
-
log('
|
|
57
|
+
log(`tabs created: Ops=${ops}, Dev=${dev}`);
|
|
58
|
+
log('add to src/routes.ts:');
|
|
59
59
|
log(` ${JSON.stringify(projectKey)}: { chat: '${chatId}', ops: ${ops}, dev: ${dev} },`);
|
|
60
60
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mikitasazan/notify",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
4
4
|
"description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"build": "tsc -p tsconfig.build.json",
|
|
34
34
|
"prepare": "tsc -p tsconfig.build.json",
|
|
35
35
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
36
|
-
"test": "node --test src/*.test.ts"
|
|
36
|
+
"test": "node --test src/*.test.ts",
|
|
37
|
+
"catalogue": "npm run build && node catalogue/build.mjs && node catalogue/assemble.mjs"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"@types/node": "^24.13.3",
|