@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 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('использование: notify setup <chat_id форума> <ключ-проекта>');
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}: ожидается число, получено «${raw ?? ''}»`);
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 ?? ''}" (${Object.keys(PR_ALIASES).join(', ')})`);
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 : String(err)}`);
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: обязателен для file');
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 : String(err)}`);
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
- /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
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
- /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
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`, который у CI не используется. */
142
+ /** Ссылка на прогон (workflow run) — отдельно от `url` запасной для `workflowUrl`. */
143
143
  workflowUrl?: string;
144
- /** Название прогона для видимого текста ссылки (по умолчанию — просто "run"). */
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
- const tail = ['b', 'a', 'i', 'u', 'code', 'blockquote']
50
- .filter((t) => {
51
- const opened = (body.match(new RegExp(`<${t}[ >]`, 'g')) ?? []).length;
52
- const closed = (body.match(new RegExp(`</${t}>`, 'g')) ?? []).length;
53
- return opened > closed;
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(`неизвестный тип события: ${String(e.type)}`);
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 не прошёл, пробуем curl…');
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('нет OPS_BOT_TOKEN сообщение не отправлено');
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 не похож на токен Telegram отправка отменена');
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(`файл не отправлен: ${err.message}`);
209
+ log(`failed to send the file: ${err.message}`);
210
210
  return { outcome: 'fail' };
211
211
  }
212
- log(`сеть не пустила файл: ${err instanceof Error ? err.message : String(err)}`);
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('нет валидного OPS_BOT_TOKEN файл не отправлен');
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(`неизвестный проект «${String(project)}»известны: ${Object.keys(ROUTES).join(', ')}`);
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: `проект «${String(project)}» не в ROUTES — событие «${kind}» никуда не доставлено`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",