@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 +25 -1
- package/dist/cli.js +34 -8
- package/dist/events.d.ts +34 -3
- package/dist/render.d.ts +7 -1
- package/dist/render.js +48 -3
- package/dist/routes.js +6 -1
- package/dist/send.d.ts +6 -0
- package/dist/send.js +110 -1
- package/package.json +4 -4
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
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/
|
|
9
|
+
"url": "git+https://github.com/sazanwork/notify.git"
|
|
10
10
|
},
|
|
11
11
|
"bugs": {
|
|
12
|
-
"url": "https://github.com/
|
|
12
|
+
"url": "https://github.com/sazanwork/notify/issues"
|
|
13
13
|
},
|
|
14
|
-
"homepage": "https://github.com/
|
|
14
|
+
"homepage": "https://github.com/sazanwork/notify#readme",
|
|
15
15
|
"publishConfig": {
|
|
16
16
|
"access": "public"
|
|
17
17
|
},
|