@mikitasazan/notify 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +30 -18
- package/dist/events.d.ts +5 -5
- package/dist/render.js +22 -7
- package/dist/send.js +14 -14
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -26,13 +26,25 @@ import { render } from "./render.js";
|
|
|
26
26
|
import { notify } from "./send.js";
|
|
27
27
|
import { setupTopic } from "./setup.js";
|
|
28
28
|
const log = (msg) => console.error(`[notify] ${msg}`);
|
|
29
|
+
/**
|
|
30
|
+
* Anything taken from the caller and echoed into a message goes through this.
|
|
31
|
+
*
|
|
32
|
+
* `sent`, `failed` and `skipped` are a CONTRACT: `notify-fail.sh` greps
|
|
33
|
+
* `^\[notify\] sent$`, the server-side silence watchdog matches `*sent*`, and
|
|
34
|
+
* `action.yml` warns on `*failed*|*skipped*`. An input value carrying one of
|
|
35
|
+
* those words puts it on stderr — `notify sent --project=x` printed
|
|
36
|
+
* `unknown event type: sent`, and the watchdog read a card that was never
|
|
37
|
+
* delivered as delivered, then stopped repeating the alarm. Found by Codex,
|
|
38
|
+
* 25.08.2026.
|
|
39
|
+
*/
|
|
40
|
+
const safe = (v) => String(v ?? '').replace(/\b(sent|failed|skipped)\b/gi, (w) => `${w[0]}·${w.slice(1)}`);
|
|
29
41
|
const args = process.argv.slice(2);
|
|
30
42
|
const command = args[0];
|
|
31
43
|
if (command === 'setup') {
|
|
32
44
|
const [, chatId, projectKey] = args;
|
|
33
45
|
if (!chatId || !projectKey) {
|
|
34
|
-
log('
|
|
35
|
-
log('
|
|
46
|
+
log('usage: notify setup <forum chat_id> <project key>');
|
|
47
|
+
log(' create the group first, turn Topics on in it, add the bot as admin');
|
|
36
48
|
process.exit(0);
|
|
37
49
|
}
|
|
38
50
|
await setupTopic(chatId, projectKey);
|
|
@@ -45,7 +57,7 @@ const BOOLEAN_FLAGS = new Set(['json', 'recovered', 'dry-run']);
|
|
|
45
57
|
for (let i = 1; i < args.length; i++) {
|
|
46
58
|
const arg = args[i];
|
|
47
59
|
if (!arg.startsWith('--')) {
|
|
48
|
-
parseErrors.push(`stray argument with no flag: "${arg}"`);
|
|
60
|
+
parseErrors.push(`stray argument with no flag: "${safe(arg)}"`);
|
|
49
61
|
continue;
|
|
50
62
|
}
|
|
51
63
|
// Форма `--key=value` обязательна для значений, начинающихся с `--`
|
|
@@ -67,7 +79,7 @@ for (let i = 1; i < args.length; i++) {
|
|
|
67
79
|
// ТЕРЯЛОСЬ ВСЁ сообщение, а `--status` без значения рисовал 🔴 на
|
|
68
80
|
// успешном деплое. Теперь это явная ошибка разбора.
|
|
69
81
|
if (next === undefined || next.startsWith('--')) {
|
|
70
|
-
parseErrors.push(`flag --${key} with no value`);
|
|
82
|
+
parseErrors.push(`flag --${safe(key)} with no value`);
|
|
71
83
|
continue;
|
|
72
84
|
}
|
|
73
85
|
i++;
|
|
@@ -76,7 +88,7 @@ for (let i = 1; i < args.length; i++) {
|
|
|
76
88
|
const one = (key) => flags.get(key)?.[0];
|
|
77
89
|
for (const key of flags.keys()) {
|
|
78
90
|
if (!KNOWN_FLAGS.has(key)) {
|
|
79
|
-
parseErrors.push(`unknown flag --${key}`);
|
|
91
|
+
parseErrors.push(`unknown flag --${safe(key)}`);
|
|
80
92
|
}
|
|
81
93
|
}
|
|
82
94
|
// Число с явной ошибкой разбора, иначе рендер рисовал «PR #NaN».
|
|
@@ -84,7 +96,7 @@ const num = (key) => {
|
|
|
84
96
|
const raw = one(key);
|
|
85
97
|
const n = Number(raw);
|
|
86
98
|
if (raw === undefined || Number.isNaN(n)) {
|
|
87
|
-
parseErrors.push(`--${key}:
|
|
99
|
+
parseErrors.push(`--${safe(key)}: expected a number, got "${safe(raw)}"`);
|
|
88
100
|
return 0;
|
|
89
101
|
}
|
|
90
102
|
return n;
|
|
@@ -121,7 +133,7 @@ const ISSUE_ALIASES = {
|
|
|
121
133
|
const prAction = (raw) => {
|
|
122
134
|
const hit = PR_ALIASES[(raw ?? '').toLowerCase()];
|
|
123
135
|
if (!hit) {
|
|
124
|
-
parseErrors.push(`--action: unknown PR action "${raw
|
|
136
|
+
parseErrors.push(`--action: unknown PR action "${safe(raw)}" (${Object.keys(PR_ALIASES).join(', ')})`);
|
|
125
137
|
return 'opened';
|
|
126
138
|
}
|
|
127
139
|
return hit;
|
|
@@ -166,7 +178,7 @@ if (flags.has('json')) {
|
|
|
166
178
|
catch (err) {
|
|
167
179
|
// Тоже в parseErrors: обе аналитики зовут CLI через `|| true`, и молчащий
|
|
168
180
|
// разбор JSON означал бы зелёный крон без дневного отчёта.
|
|
169
|
-
parseErrors.push(`--json from stdin did not parse: ${err instanceof Error ? err.message :
|
|
181
|
+
parseErrors.push(`--json from stdin did not parse: ${safe(err instanceof Error ? err.message : err)}`);
|
|
170
182
|
}
|
|
171
183
|
}
|
|
172
184
|
else {
|
|
@@ -192,7 +204,7 @@ else {
|
|
|
192
204
|
event = {
|
|
193
205
|
type: 'job',
|
|
194
206
|
project: project(),
|
|
195
|
-
job: one('job') ?? '(
|
|
207
|
+
job: one('job') ?? '(no name)',
|
|
196
208
|
status: jobStatus(),
|
|
197
209
|
stats: pairs('stat'),
|
|
198
210
|
items: items(),
|
|
@@ -206,7 +218,7 @@ else {
|
|
|
206
218
|
event = {
|
|
207
219
|
type: 'report',
|
|
208
220
|
project: project(),
|
|
209
|
-
title: one('title') ?? '(
|
|
221
|
+
title: one('title') ?? '(no title)',
|
|
210
222
|
period: one('period'),
|
|
211
223
|
lines: pairs('line'),
|
|
212
224
|
items: items(),
|
|
@@ -236,7 +248,7 @@ else {
|
|
|
236
248
|
project: project(),
|
|
237
249
|
action: prAction(one('action')),
|
|
238
250
|
number: num('number'),
|
|
239
|
-
title: one('title') ?? '(
|
|
251
|
+
title: one('title') ?? '(no title)',
|
|
240
252
|
body: one('body'),
|
|
241
253
|
author: one('author'),
|
|
242
254
|
reviewer: one('reviewer'),
|
|
@@ -249,7 +261,7 @@ else {
|
|
|
249
261
|
project: project(),
|
|
250
262
|
action: issueAction(one('action')),
|
|
251
263
|
number: num('number'),
|
|
252
|
-
title: one('title') ?? '(
|
|
264
|
+
title: one('title') ?? '(no title)',
|
|
253
265
|
body: one('body'),
|
|
254
266
|
author: one('author'),
|
|
255
267
|
assignee: one('assignee'),
|
|
@@ -260,7 +272,7 @@ else {
|
|
|
260
272
|
event = {
|
|
261
273
|
type: 'incident',
|
|
262
274
|
project: project(),
|
|
263
|
-
title: one('title') ?? '(
|
|
275
|
+
title: one('title') ?? '(no title)',
|
|
264
276
|
detail: one('detail'),
|
|
265
277
|
logs: one('logs'),
|
|
266
278
|
url: one('url')
|
|
@@ -270,7 +282,7 @@ else {
|
|
|
270
282
|
event = {
|
|
271
283
|
type: 'heartbeat_miss',
|
|
272
284
|
project: project(),
|
|
273
|
-
job: one('job') ?? '(
|
|
285
|
+
job: one('job') ?? '(no name)',
|
|
274
286
|
lastSeen: one('last-seen'),
|
|
275
287
|
expected: one('expected'),
|
|
276
288
|
recovered: flags.has('recovered'),
|
|
@@ -280,12 +292,12 @@ else {
|
|
|
280
292
|
case 'file': {
|
|
281
293
|
const path = one('path');
|
|
282
294
|
if (!path) {
|
|
283
|
-
parseErrors.push('--path:
|
|
295
|
+
parseErrors.push('--path: required for a file event');
|
|
284
296
|
}
|
|
285
297
|
event = {
|
|
286
298
|
type: 'file',
|
|
287
299
|
project: project(),
|
|
288
|
-
title: one('title') ?? '(
|
|
300
|
+
title: one('title') ?? '(no title)',
|
|
289
301
|
path: path ?? '',
|
|
290
302
|
filename: one('filename'),
|
|
291
303
|
note: one('note')
|
|
@@ -296,7 +308,7 @@ else {
|
|
|
296
308
|
// В parseErrors, а не просто в лог: иначе неизвестный тип уходил в
|
|
297
309
|
// тишину — событие не собиралось, ошибок разбора не было, и CLI выходил
|
|
298
310
|
// нулём, ничего не отправив и ничего об этом не сказав.
|
|
299
|
-
parseErrors.push(`unknown event type: ${command ?? '(none given)'}`);
|
|
311
|
+
parseErrors.push(`unknown event type: ${safe(command ?? '(none given)')}`);
|
|
300
312
|
}
|
|
301
313
|
}
|
|
302
314
|
// --key применим к любому типу — одна точка вместо строки в каждом case
|
|
@@ -343,7 +355,7 @@ if (event) {
|
|
|
343
355
|
catch (err) {
|
|
344
356
|
// Слово `failed` — контракт, тот же, что у ошибки разбора выше. Без него
|
|
345
357
|
// исключение при отправке читалось сторожами как «ничего не случилось».
|
|
346
|
-
log(`failed: ${err instanceof Error ? err.message :
|
|
358
|
+
log(`failed: ${safe(err instanceof Error ? err.message : err)}`);
|
|
347
359
|
}
|
|
348
360
|
}
|
|
349
361
|
process.exit(0);
|
package/dist/events.d.ts
CHANGED
|
@@ -49,12 +49,12 @@ export type NotifyEvent = Keyed & (
|
|
|
49
49
|
commit?: string;
|
|
50
50
|
/** Ссылка на коммит — строка «коммит» становится кликабельной. */
|
|
51
51
|
commitUrl?: string;
|
|
52
|
-
/** Заголовок коммита — рендерится
|
|
52
|
+
/** Заголовок коммита — рендерится полем `Title:`, тело идёт цитатой ниже. */
|
|
53
53
|
commitTitle?: string;
|
|
54
54
|
/** Тело коммита, если есть — та же цитата, что и заголовок. */
|
|
55
55
|
commitBody?: string;
|
|
56
56
|
workflowUrl?: string;
|
|
57
|
-
/** Название прогона для видимого текста ссылки (по умолчанию —
|
|
57
|
+
/** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
|
|
58
58
|
workflowName?: string;
|
|
59
59
|
url?: string;
|
|
60
60
|
/**
|
|
@@ -85,7 +85,7 @@ export type NotifyEvent = Keyed & (
|
|
|
85
85
|
items?: Item[];
|
|
86
86
|
note?: string;
|
|
87
87
|
workflowUrl?: string;
|
|
88
|
-
/** Название прогона для видимого текста ссылки (по умолчанию —
|
|
88
|
+
/** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
|
|
89
89
|
workflowName?: string;
|
|
90
90
|
/**
|
|
91
91
|
* Запасное имя для ссылки на прогон: половина отправителей шлёт её как
|
|
@@ -139,9 +139,9 @@ export type NotifyEvent = Keyed & (
|
|
|
139
139
|
* schedule, a manual press. Renders as `Reason:`, same as on deploy.
|
|
140
140
|
*/
|
|
141
141
|
note?: string;
|
|
142
|
-
/** Ссылка на прогон (workflow run) — отдельно от `url
|
|
142
|
+
/** Ссылка на прогон (workflow run) — отдельно от `url` — запасной для `workflowUrl`. */
|
|
143
143
|
workflowUrl?: string;
|
|
144
|
-
/** Название прогона для видимого текста ссылки (по умолчанию —
|
|
144
|
+
/** Название прогона для видимого текста ссылки (по умолчанию — `open`). */
|
|
145
145
|
workflowName?: string;
|
|
146
146
|
url?: string;
|
|
147
147
|
}
|
package/dist/render.js
CHANGED
|
@@ -46,12 +46,27 @@ export const clampMessage = (text, limit = 4000) => {
|
|
|
46
46
|
// Telegram отвечает на такое 400 — то есть карточка пропадала целиком, а
|
|
47
47
|
// отправитель с `|| true` этого не замечал. Нашёл Codex; воспроизводится
|
|
48
48
|
// отчётом с именем группы в 5000 знаков.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
//
|
|
50
|
+
// Порядок закрытия — обратный порядку ОТКРЫТИЯ, а не фиксированный список.
|
|
51
|
+
// Фиксированный список закрывал `<i><u>` как `</i></u>`: тегов поровну,
|
|
52
|
+
// счётчик сходится, вложенность нарушена, и Telegram отвечает тем же 400.
|
|
53
|
+
// Второй заход того же бага (25.08.2026), поэтому теперь порядок берётся из
|
|
54
|
+
// самого текста: последний открытый закрывается первым.
|
|
55
|
+
const open = [];
|
|
56
|
+
const tagRe = /<(\/?)(b|a|i|u|code|blockquote)[ >]/g;
|
|
57
|
+
for (let m = tagRe.exec(body); m !== null; m = tagRe.exec(body)) {
|
|
58
|
+
if (m[1] === '/') {
|
|
59
|
+
const at = open.lastIndexOf(m[2]);
|
|
60
|
+
if (at !== -1) {
|
|
61
|
+
open.splice(at, 1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
open.push(m[2]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const tail = open
|
|
69
|
+
.reverse()
|
|
55
70
|
.map((t) => `</${t}>`)
|
|
56
71
|
.join('');
|
|
57
72
|
return `${body}${tail}\n…`;
|
|
@@ -409,7 +424,7 @@ export const render = (e) => {
|
|
|
409
424
|
// строка, и неизвестное значение роняло процесс через `renderer is not a
|
|
410
425
|
// function`. Падать из-за уведомления нельзя.
|
|
411
426
|
if (typeof renderer !== 'function') {
|
|
412
|
-
throw new Error(
|
|
427
|
+
throw new Error(`unknown event type: ${String(e.type)}`);
|
|
413
428
|
}
|
|
414
429
|
const tags = tagsLine(e);
|
|
415
430
|
// clampMessage может выйти за переданный limit на хвост закрывающих тегов и
|
package/dist/send.js
CHANGED
|
@@ -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);
|
package/package.json
CHANGED