@mikitasazan/notify 1.0.3 → 1.1.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/README.md CHANGED
@@ -86,9 +86,24 @@ notify report --project playhub --json < payload.json # весь объект
86
86
  | `issue` | событие задачи | `project`, `action`, `number`, `title` |
87
87
  | `incident` | приложение сломалось прямо сейчас | `project`, `title` |
88
88
  | `heartbeat_miss` | задача не отметилась вовремя | `project`, `job` |
89
+ | `file` | файл-вложение с подписью-карточкой | `project`, `title`, `path` |
89
90
 
90
91
  Полные сигнатуры — `src/events.ts`.
91
92
 
93
+ **Ключ задачи.** Последняя строка каждой карточки — `#проект/ключ` в `<code>`.
94
+ По нему дневной разборщик сверяет «это 🔴 уже закрыто более поздней карточкой
95
+ той же задачи?» без сравнения человеческих формулировок. Явный `--key`
96
+ побеждает; без него ключ выводится из заголовка и меняется вместе с ним —
97
+ регулярный отправитель передаёт `--key` явно. В stdout CLI ключ не попадает.
98
+
99
+ **Единственная дверь для свободного HTML** — `sendReport()` (`report --json` у
100
+ аналитик): плотную строку дневного отчёта не разложить в `label=value` не
101
+ испортив. Стандартизирован транспорт, формат — нет, и только здесь.
102
+
103
+ **Неизвестный проект** не роняет вызвавший крон (код возврата 0), но больше и
104
+ не исчезает молча: в mac-config Ops уходит красная карточка «notify: событие
105
+ потеряно». До 18.08.2026 опечатка в `--project` терялась без следа неделями.
106
+
92
107
  `--action` у `pr`: `opened`, `ready_for_review`, `review_requested`, `approved`,
93
108
  `changes_requested`, `merged`, `closed`. У `issue`: `opened`, `assigned`,
94
109
  `closed`. Принимаются и сырые имена GitHub (`reopened`,
@@ -113,7 +128,16 @@ CLI), однако общий workflow `.github/workflows/ops-notify.yml` их
113
128
  живому аккаунту, ботом это не сделать.
114
129
  2. `notify setup <chat_id> maphub` — заведёт вкладки «⚙️ Ops» и «💬 Dev» и
115
130
  напечатает готовую строку.
116
- 3. Вставить строку в `src/routes.ts`.
131
+ 3. Вставить строку в `src/routes.ts` (и значение в тип `Project` в
132
+ `src/events.ts`), выпустить пакет.
133
+ 4. В репозиторий проекта — трёхстрочный `.github/workflows/notify.yml`,
134
+ вызывающий `mikitasazan/notify/.github/workflows/ops-notify.yml@v1`:
135
+ CI, деплой, PR и задачи начинают приходить сами.
136
+ 5. Закрепить в «⚙️ Ops» легенду значков (пример — в любом существующем
137
+ форуме) и, если задача проекта бежит с мака, — добавить её плист под
138
+ обёртку `run-scheduled.sh` (репозиторий mac-config).
139
+
140
+ Весь путь — минут десять, единственный ручной шаг — п. 1.
117
141
 
118
142
  **Появились сотрудники на проекте** — просто добавь их в форум этого проекта.
119
143
  Ничего не мигрируется и не дублируется: они видят Ops и Dev своего проекта и
package/dist/cli.js CHANGED
@@ -15,6 +15,8 @@
15
15
  * notify ci --project arvent --status fail --branch master --actor saz_sam
16
16
  * notify pr --project arvent --action opened --number 142 --title "..."
17
17
  * notify incident --project arvent --title "Redis недоступен" --detail "$ERR"
18
+ * notify file --project arvent --title "Полные диалоги" --path ./out.txt [--filename имя.txt]
19
+ * notify <type> [--key стабильный-ключ] # ключ задачи в последней строке карточки
18
20
  * notify <type> --json < payload.json # весь объект события со stdin
19
21
  * notify setup <chat_id форума> <ключ-проекта> # создать вкладки, см. setup.ts
20
22
  */
@@ -161,7 +163,8 @@ else {
161
163
  url: one('url'),
162
164
  target: one('target'),
163
165
  via: one('via'),
164
- note: one('note')
166
+ note: one('note'),
167
+ key: one('key')
165
168
  };
166
169
  break;
167
170
  case 'job':
@@ -173,7 +176,8 @@ else {
173
176
  stats: pairs('stat'),
174
177
  items: items(),
175
178
  note: one('note'),
176
- url: one('url')
179
+ url: one('url'),
180
+ key: one('key')
177
181
  };
178
182
  break;
179
183
  case 'report':
@@ -184,7 +188,8 @@ else {
184
188
  period: one('period'),
185
189
  lines: pairs('line'),
186
190
  items: items(),
187
- url: one('url')
191
+ url: one('url'),
192
+ key: one('key')
188
193
  };
189
194
  break;
190
195
  case 'ci':
@@ -195,7 +200,8 @@ else {
195
200
  branch: one('branch'),
196
201
  commit: one('commit'),
197
202
  actor: one('actor'),
198
- url: one('url')
203
+ url: one('url'),
204
+ key: one('key')
199
205
  };
200
206
  break;
201
207
  case 'pr':
@@ -207,7 +213,8 @@ else {
207
213
  title: one('title') ?? '(без заголовка)',
208
214
  author: one('author'),
209
215
  reviewer: one('reviewer'),
210
- url: one('url')
216
+ url: one('url'),
217
+ key: one('key')
211
218
  };
212
219
  break;
213
220
  case 'issue':
@@ -219,7 +226,8 @@ else {
219
226
  title: one('title') ?? '(без заголовка)',
220
227
  author: one('author'),
221
228
  assignee: one('assignee'),
222
- url: one('url')
229
+ url: one('url'),
230
+ key: one('key')
223
231
  };
224
232
  break;
225
233
  case 'incident':
@@ -228,7 +236,8 @@ else {
228
236
  project: project(),
229
237
  title: one('title') ?? '(без заголовка)',
230
238
  detail: one('detail'),
231
- url: one('url')
239
+ url: one('url'),
240
+ key: one('key')
232
241
  };
233
242
  break;
234
243
  case 'heartbeat_miss':
@@ -237,9 +246,26 @@ else {
237
246
  project: project(),
238
247
  job: one('job') ?? '(без имени)',
239
248
  lastSeen: one('last-seen'),
240
- expected: one('expected')
249
+ expected: one('expected'),
250
+ key: one('key')
251
+ };
252
+ break;
253
+ case 'file': {
254
+ const path = one('path');
255
+ if (!path) {
256
+ parseErrors.push('--path: обязателен для file');
257
+ }
258
+ event = {
259
+ type: 'file',
260
+ project: project(),
261
+ title: one('title') ?? '(без заголовка)',
262
+ path: path ?? '',
263
+ filename: one('filename'),
264
+ note: one('note'),
265
+ key: one('key')
241
266
  };
242
267
  break;
268
+ }
243
269
  default:
244
270
  log(`неизвестный тип события: ${command ?? '(не указан)'}`);
245
271
  }
package/dist/events.d.ts CHANGED
@@ -9,7 +9,19 @@
9
9
  * - обязательные поля не добавляются никогда — только новый тип события.
10
10
  * Тогда старый вызывающий код и новый пакет совместимы в обе стороны.
11
11
  */
12
- export type Project = 'playhub' | 'one-q' | 'arvent' | 'game-publisher' | 'vault' | 'mac-config';
12
+ export type Project = 'playhub' | 'one-q' | 'arvent' | 'game-publisher' | 'vault' | 'mac-config' | 'alitools';
13
+ /**
14
+ * Стабильный машинный ключ задачи — последняя строка каждой карточки, вида
15
+ * `#проект/ключ`. По нему дневной разборщик сверяет «это 🔴 уже закрыто более
16
+ * поздней карточкой того же ключа?» без сравнения человеческих формулировок,
17
+ * которые меняются. Необязателен: без него ключ выводится из типа и заголовка
18
+ * (см. `render.ts`), но выведенный наследует хрупкость формулировки — наши
19
+ * регулярные отправители передают его явно. В stdout CLI ключ не попадает
20
+ * никогда: сторож на VPS разбирает stdout по словам `sent|failed|skipped`.
21
+ */
22
+ type Keyed = {
23
+ key?: string;
24
+ };
13
25
  /**
14
26
  * Позиция списка внутри сообщения: задача из дайджеста, упавшая проверка,
15
27
  * замечание. `url` необязателен — тогда рендерится просто строкой.
@@ -18,7 +30,7 @@ export type Item = {
18
30
  text: string;
19
31
  url?: string;
20
32
  };
21
- export type NotifyEvent =
33
+ export type NotifyEvent = Keyed & (
22
34
  /** Выкатка кода на сервер. */
23
35
  {
24
36
  type: 'deploy';
@@ -122,7 +134,26 @@ export type NotifyEvent =
122
134
  job: string;
123
135
  lastSeen?: string;
124
136
  expected?: string;
125
- };
137
+ }
138
+ /**
139
+ * Файл-вложение (sendDocument) с подписью-карточкой. Появился, когда
140
+ * eval-отчёт Arvent слал файл голым curl мимо пакета: без ретраев файл
141
+ * терялся в любую сетевую икоту, а chat_id и номер вкладки жили копией,
142
+ * которая устаревает молча. Маршрут — тот же `ROUTES`, транспорт — с теми
143
+ * же повторами. Подпись у Telegram ограничена 1024 символами — режется
144
+ * тем же безопасным клампом.
145
+ */
146
+ | {
147
+ type: 'file';
148
+ project: Project;
149
+ title: string;
150
+ /** Путь к локальному файлу. */
151
+ path: string;
152
+ /** Имя файла в чате; по умолчанию — имя из `path`. */
153
+ filename?: string;
154
+ note?: string;
155
+ });
126
156
  export type EventType = NotifyEvent['type'];
127
157
  /** Красное = со звуком. Всё остальное — тихо. (Отдельной темы «инциденты» больше нет — авария видна в ленте проекта.) */
128
158
  export declare const severity: (e: NotifyEvent) => "info" | "error";
159
+ export {};
package/dist/render.d.ts CHANGED
@@ -27,5 +27,11 @@ export declare const esc: (v: unknown) => string;
27
27
  * повторяем — сообщение исчезало совсем.
28
28
  */
29
29
  export declare const clampMessage: (text: string, limit?: number) => string;
30
- /** Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram. */
30
+ export declare const eventKey: (e: NotifyEvent) => string;
31
+ /**
32
+ * Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
33
+ * Ключ добавляется ПОСЛЕ обрезки, с зарезервированным местом: обрезанная
34
+ * карточка без ключа была бы невидима разборщику — ровно на самых длинных,
35
+ * то есть самых важных сообщениях.
36
+ */
31
37
  export declare const render: (e: NotifyEvent) => string;
package/dist/render.js CHANGED
@@ -152,6 +152,8 @@ const renderHeartbeatMiss = (e) => join([
152
152
  kv('последний раз', e.lastSeen),
153
153
  kv('ожидалось', e.expected)
154
154
  ]);
155
+ // Подпись файла — та же карточка, но лимит Telegram у caption свой: 1024.
156
+ const renderFile = (e) => join([header('📄', e.title, e.project), kv('примечание', e.note)]);
155
157
  const RENDERERS = {
156
158
  deploy: renderDeploy,
157
159
  job: renderJob,
@@ -160,9 +162,47 @@ const RENDERERS = {
160
162
  pr: renderPr,
161
163
  issue: renderIssue,
162
164
  incident: renderIncident,
163
- heartbeat_miss: renderHeartbeatMiss
165
+ heartbeat_miss: renderHeartbeatMiss,
166
+ file: renderFile
164
167
  };
165
- /** Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram. */
168
+ /**
169
+ * Ключ задачи — последняя строка карточки: `#проект/ключ` в <code>. Явный
170
+ * `key` побеждает; выведенный строится из заголовка и наследует хрупкость
171
+ * формулировки — регулярные отправители передают явный. Ключ переживает
172
+ * MTProto-чтение (это простой текст, не разметка), по нему разборщик сверяет
173
+ * 🔴 с более поздней успешной карточкой той же задачи.
174
+ */
175
+ const slug = (raw) => raw
176
+ .toLowerCase()
177
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
178
+ .replace(/^-+|-+$/g, '');
179
+ export const eventKey = (e) => {
180
+ const fallback = () => {
181
+ switch (e.type) {
182
+ case 'job':
183
+ case 'heartbeat_miss':
184
+ return slug(e.job);
185
+ case 'report':
186
+ case 'incident':
187
+ case 'file':
188
+ return slug(e.title);
189
+ case 'pr':
190
+ return `pr-${e.number}`;
191
+ case 'issue':
192
+ return `issue-${e.number}`;
193
+ default:
194
+ return e.type;
195
+ }
196
+ };
197
+ return `#${slug(e.project)}/${e.key ? slug(e.key) : fallback()}`;
198
+ };
199
+ const keyLine = (e) => `<code>${esc(eventKey(e))}</code>`;
200
+ /**
201
+ * Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram.
202
+ * Ключ добавляется ПОСЛЕ обрезки, с зарезервированным местом: обрезанная
203
+ * карточка без ключа была бы невидима разборщику — ровно на самых длинных,
204
+ * то есть самых важных сообщениях.
205
+ */
166
206
  export const render = (e) => {
167
207
  const renderer = RENDERERS[e.type];
168
208
  // Прикрывает путь `--json` и вызовы из JS без типов: там `type` — обычная
@@ -171,5 +211,10 @@ export const render = (e) => {
171
211
  if (typeof renderer !== 'function') {
172
212
  throw new Error(`неизвестный тип события: ${String(e.type)}`);
173
213
  }
174
- return clampMessage(renderer(e));
214
+ const tag = keyLine(e);
215
+ // clampMessage может выйти за переданный limit на хвост закрывающих тегов и
216
+ // многоточие — минус 40 оставляет ему этот запас. У сообщений свой запас уже
217
+ // есть (4000 против 4096 у Telegram), у caption лимит 1024 настоящий.
218
+ const budget = e.type === 'file' ? 1024 - tag.length - 40 : 4000 - tag.length - 1;
219
+ return `${clampMessage(renderer(e), budget)}\n${tag}`;
175
220
  };
package/dist/routes.js CHANGED
@@ -12,7 +12,12 @@ export const ROUTES = {
12
12
  vault: { chat: '-1004459314999', ops: 3 },
13
13
  // Тоже инфраструктура, без `dev`: дневной дайджест задач и понедельничная
14
14
  // сводка спотыканий. До этой строки оба тихо умирали на «неизвестный проект».
15
- 'mac-config': { chat: '-1004442522004', ops: 2 }
15
+ 'mac-config': { chat: '-1004442522004', ops: 2 },
16
+ // Корпоративный проект владельца: только его собственные отчёты «что дала
17
+ // выливка» (ali98x-sentry). Без `dev` — команда проекта живёт в чужих
18
+ // системах, людей здесь нет. До 18.08.2026 отчёты терялись на «неизвестный
19
+ // проект» неделями.
20
+ alitools: { chat: '-1003904331479', ops: 3 }
16
21
  };
17
22
  /**
18
23
  * Куда уходит событие. Всё — во вкладку «Ops» своего проекта; красное
package/dist/send.d.ts CHANGED
@@ -4,6 +4,12 @@ export type SendResult = 'sent' | 'skipped' | 'failed';
4
4
  * Отправляет событие во все его цели (тема проекта + при необходимости
5
5
  * `incidents` + чат команды). Цели идут последовательно; провал одной не
6
6
  * отменяет остальные. `'sent'`, если хотя бы одна цель получила сообщение.
7
+ *
8
+ * Неизвестный проект по-прежнему НЕ роняет вызвавший крон (код возврата не
9
+ * меняется) — но и не исчезает молча: в mac-config Ops уходит красная
10
+ * карточка. Дважды этот класс провала жил незамеченным неделями: «vault» и
11
+ * «mac-config» до 04.08, отчёты Alitools — до 18.08. Рекурсия невозможна:
12
+ * карточка-ошибка адресована mac-config, который в ROUTES есть всегда.
7
13
  */
8
14
  export declare const notify: (e: NotifyEvent) => Promise<SendResult>;
9
15
  /**
package/dist/send.js CHANGED
@@ -13,6 +13,8 @@
13
13
  * деплой или регулярную задачу, которая его вызвала.
14
14
  */
15
15
  import { execFileSync } from 'node:child_process';
16
+ import { readFileSync } from 'node:fs';
17
+ import { basename } from 'node:path';
16
18
  import { clampMessage, render } from "./render.js";
17
19
  import { ROUTES, targets } from "./routes.js";
18
20
  const log = (msg) => {
@@ -157,12 +159,119 @@ const deliver = async (where, text) => {
157
159
  }
158
160
  return results.includes('sent') ? 'sent' : 'failed';
159
161
  };
162
+ /**
163
+ * Файл (sendDocument) — multipart, поэтому не через `buildBody`. Политика
164
+ * повторов та же, что у сообщений: 429 с уважением retry_after, 5xx —
165
+ * повтор, прочие 4xx и таймаут — нет (дубль файла хуже пропуска).
166
+ * Отдельного curl-фолбэка нет: файл шлётся с этой же машины, а не из CI,
167
+ * и другой TLS-стек здесь ни разу не понадобился.
168
+ */
169
+ const sendFileOnce = async (token, target, e, caption) => {
170
+ try {
171
+ const form = new FormData();
172
+ form.append('chat_id', target.chat);
173
+ if (target.thread) {
174
+ form.append('message_thread_id', String(target.thread));
175
+ }
176
+ form.append('caption', caption);
177
+ form.append('parse_mode', 'HTML');
178
+ form.append('disable_notification', String(target.silent));
179
+ // readFileSync + Blob, не openAsBlob: тот появился в Node 19.8, а пакет
180
+ // бегает и на сервере. Файлы здесь — текстовые отчёты, память не вопрос.
181
+ form.append('document', new Blob([readFileSync(e.path)]), e.filename ?? basename(e.path));
182
+ const res = await fetch(`https://api.telegram.org/bot${token}/sendDocument`, {
183
+ method: 'POST',
184
+ body: form,
185
+ signal: AbortSignal.timeout(60_000)
186
+ });
187
+ if (res.ok) {
188
+ return { outcome: 'ok' };
189
+ }
190
+ if (res.status === 429) {
191
+ const body = (await res.json().catch(() => null));
192
+ const retryAfter = typeof body?.parameters?.retry_after === 'number' ? body.parameters.retry_after : 5;
193
+ return { outcome: 'retry', waitMs: Math.min(retryAfter, 60) * 1000 };
194
+ }
195
+ if (res.status >= 500) {
196
+ return { outcome: 'retry', waitMs: 1000 };
197
+ }
198
+ const detail = (await res.json().catch(() => null));
199
+ log(`HTTP ${res.status}: ${detail?.description ?? 'без описания'} — не повторяем, ошибка постоянная`);
200
+ return { outcome: 'fail' };
201
+ }
202
+ catch (err) {
203
+ if (err instanceof Error && err.name === 'TimeoutError') {
204
+ log('таймаут ответа — не повторяем: файл мог уже уйти');
205
+ return { outcome: 'fail' };
206
+ }
207
+ // Файл не читается (нет на диске, нет прав) — постоянная ошибка.
208
+ if (err instanceof Error && 'code' in err) {
209
+ log(`файл не отправлен: ${err.message}`);
210
+ return { outcome: 'fail' };
211
+ }
212
+ log(`сеть не пустила файл: ${err instanceof Error ? err.message : String(err)}`);
213
+ return { outcome: 'retry', waitMs: 1000 };
214
+ }
215
+ };
216
+ const sendFile = async (e) => {
217
+ const token = process.env.OPS_BOT_TOKEN?.trim();
218
+ if (!token || !/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
219
+ log('нет валидного OPS_BOT_TOKEN — файл не отправлен');
220
+ return 'skipped';
221
+ }
222
+ const where = targets(e);
223
+ if (where.length === 0) {
224
+ return 'skipped';
225
+ }
226
+ const caption = render(e);
227
+ for (const target of where) {
228
+ let waitMs = 0;
229
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
230
+ if (waitMs > 0) {
231
+ await sleep(waitMs);
232
+ }
233
+ const result = await sendFileOnce(token, target, e, caption);
234
+ if (result.outcome === 'ok') {
235
+ return 'sent';
236
+ }
237
+ if (result.outcome === 'fail') {
238
+ return 'failed';
239
+ }
240
+ waitMs = result.waitMs;
241
+ }
242
+ }
243
+ log('исчерпаны попытки отправки файла');
244
+ return 'failed';
245
+ };
160
246
  /**
161
247
  * Отправляет событие во все его цели (тема проекта + при необходимости
162
248
  * `incidents` + чат команды). Цели идут последовательно; провал одной не
163
249
  * отменяет остальные. `'sent'`, если хотя бы одна цель получила сообщение.
250
+ *
251
+ * Неизвестный проект по-прежнему НЕ роняет вызвавший крон (код возврата не
252
+ * меняется) — но и не исчезает молча: в mac-config Ops уходит красная
253
+ * карточка. Дважды этот класс провала жил незамеченным неделями: «vault» и
254
+ * «mac-config» до 04.08, отчёты Alitools — до 18.08. Рекурсия невозможна:
255
+ * карточка-ошибка адресована mac-config, который в ROUTES есть всегда.
164
256
  */
165
- export const notify = async (e) => deliver(targets(e), render(e));
257
+ export const notify = async (e) => {
258
+ if (!(e.project in ROUTES)) {
259
+ const lost = {
260
+ type: 'job',
261
+ project: 'mac-config',
262
+ job: 'notify: событие потеряно',
263
+ status: 'fail',
264
+ note: `проект «${String(e.project)}» не в ROUTES — событие «${String(e.type)}» никуда не доставлено`,
265
+ key: 'notify-unknown-project'
266
+ };
267
+ await deliver(targets(lost), render(lost)).catch(() => undefined);
268
+ return 'skipped';
269
+ }
270
+ if (e.type === 'file') {
271
+ return sendFile(e);
272
+ }
273
+ return deliver(targets(e), render(e));
274
+ };
166
275
  /**
167
276
  * Готовый HTML во вкладку «Ops» проекта — ТОЛЬКО для дневных отчётов.
168
277
  *
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.0.3",
3
+ "version": "1.1.1",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/mikitasazan/notify.git"
9
+ "url": "git+https://github.com/sazanwork/notify.git"
10
10
  },
11
11
  "bugs": {
12
- "url": "https://github.com/mikitasazan/notify/issues"
12
+ "url": "https://github.com/sazanwork/notify/issues"
13
13
  },
14
- "homepage": "https://github.com/mikitasazan/notify#readme",
14
+ "homepage": "https://github.com/sazanwork/notify#readme",
15
15
  "publishConfig": {
16
16
  "access": "public"
17
17
  },