@mikitasazan/notify 1.0.0
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 +160 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +255 -0
- package/dist/events.d.ts +126 -0
- package/dist/events.js +21 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/render.d.ts +31 -0
- package/dist/render.js +174 -0
- package/dist/routes.d.ts +47 -0
- package/dist/routes.js +30 -0
- package/dist/send.d.ts +26 -0
- package/dist/send.js +177 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +45 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# @mikitasazan/notify
|
|
2
|
+
|
|
3
|
+
Единая типизированная отправка Telegram-уведомлений: **форум на проект**,
|
|
4
|
+
внутри вкладки «⚙️ Ops» (роботы) и «💬 Dev» (люди), один бот, семь типов
|
|
5
|
+
событий. Заменяет 18 разных имён переменных и 4+ независимых реализации
|
|
6
|
+
`jq | curl`, что были в playhub, arvent, game-publisher до 26.07.2026.
|
|
7
|
+
|
|
8
|
+
**Почему форум на проект, а не один общий форум с темой на проект.** Второе
|
|
9
|
+
пробовали первым — не работает: Telegram не умеет закрывать отдельную тему от
|
|
10
|
+
участника, кто в группе, видит все вкладки. Значит сотрудников в общий форум
|
|
11
|
+
не пустить, им нужен свой чат — и проект начинает жить в двух местах сразу
|
|
12
|
+
(«тема у владельца» + «канал у команды»). Форум на проект убирает
|
|
13
|
+
дублирование: сотрудник добавляется в форум своего проекта, чужих не видит,
|
|
14
|
+
схема одна для всех.
|
|
15
|
+
|
|
16
|
+
## Идея в одном абзаце
|
|
17
|
+
|
|
18
|
+
`notify()` принимает объект (`NotifyEvent`), а не строку — «своё» сообщение
|
|
19
|
+
написать нельзя. Один рендерер на тип события гарантирует одинаковый каркас:
|
|
20
|
+
`эмодзи Заголовок · проект`, затем `ключ: значение`, затем ссылка. Маршрут
|
|
21
|
+
«куда слать» вычисляется из `--project` и severity события — добавить новый
|
|
22
|
+
проект значит дописать одну строку в `src/routes.ts`, больше нигде ничего
|
|
23
|
+
заводить не нужно.
|
|
24
|
+
|
|
25
|
+
**Ноль рантайм-зависимостей.** Node 22.18+ / 24+ исполняет `.ts` напрямую
|
|
26
|
+
(проверено на macOS и на проде playhub, Node 22.22.2) — сборки нет,
|
|
27
|
+
в GitHub Actions ставить нечего.
|
|
28
|
+
|
|
29
|
+
## Установка
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install @mikitasazan/notify
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Секрет один — `OPS_BOT_TOKEN`. Взять из vault (`vault get
|
|
36
|
+
notify.OPS_BOT_TOKEN`) или из GitHub secrets репозитория.
|
|
37
|
+
|
|
38
|
+
## Из TypeScript
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { notify } from '@mikitasazan/notify';
|
|
42
|
+
|
|
43
|
+
await notify({
|
|
44
|
+
type: 'report',
|
|
45
|
+
project: 'playhub',
|
|
46
|
+
title: 'Сводка за день',
|
|
47
|
+
period: '26 июля',
|
|
48
|
+
lines: [['Игр в каталоге', 1284], ['Ошибок PM2', 3]]
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Из bash (сервер, cron, ноутбук)
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
notify job --project playhub --job "Импорт игр" --status ok --stat "добавлено=5"
|
|
56
|
+
trap 'notify job --project playhub --job "Импорт игр" --status fail --note "лог: $LOG"' ERR
|
|
57
|
+
notify report --project playhub --json < payload.json # весь объект со stdin
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Код возврата **всегда 0** — уведомление не имеет права уронить вызвавший его
|
|
61
|
+
процесс. Все ошибки — в stderr.
|
|
62
|
+
|
|
63
|
+
## Из GitHub Actions
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
- uses: mikitasazan/notify@v1.0.0 # версию пинит propagate
|
|
67
|
+
if: always()
|
|
68
|
+
with: { event: deploy, project: playhub, status: '${{ job.status }}' }
|
|
69
|
+
env: { OPS_BOT_TOKEN: '${{ secrets.OPS_BOT_TOKEN }}' }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`status: ${{ job.status }}` (`success`/`failure`) автоматически маппится в
|
|
73
|
+
`ok`/`fail` — не нужно двух шагов с `if: success()`/`if: failure()`.
|
|
74
|
+
|
|
75
|
+
## Типы событий
|
|
76
|
+
|
|
77
|
+
| Тип | Когда | Обязательные поля |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `deploy` | выкатка кода | `project`, `status` |
|
|
80
|
+
| `job` | регулярная задача (импорт, бэкап) | `project`, `job`, `status` |
|
|
81
|
+
| `report` | сводка с цифрами | `project`, `title`, `lines` |
|
|
82
|
+
| `ci` | итог CI на master | `project`, `status` |
|
|
83
|
+
| `pr` | событие пул-реквеста | `project`, `action`, `number`, `title` |
|
|
84
|
+
| `issue` | событие задачи | `project`, `action`, `number`, `title` |
|
|
85
|
+
| `incident` | приложение сломалось прямо сейчас | `project`, `title` |
|
|
86
|
+
| `heartbeat_miss` | задача не отметилась вовремя | `project`, `job` |
|
|
87
|
+
|
|
88
|
+
Полные сигнатуры — `src/events.ts`.
|
|
89
|
+
|
|
90
|
+
`--action` у `pr`: `opened`, `ready_for_review`, `review_requested`, `approved`,
|
|
91
|
+
`changes_requested`, `merged`, `closed`. У `issue`: `opened`, `assigned`,
|
|
92
|
+
`closed`. Принимаются и сырые имена GitHub (`reopened`,
|
|
93
|
+
`review_request_removed`). Неизвестное действие — ошибка разбора, а не молчаливая
|
|
94
|
+
подмена: иначе «запрошены правки» приехали бы как «открыт».
|
|
95
|
+
|
|
96
|
+
**Но из GitHub в «⚙️ Ops» уходит не всё, что пакет умеет нарисовать.**
|
|
97
|
+
`ready_for_review` и `review_requested` рендерятся (их можно послать вручную из
|
|
98
|
+
CLI), однако общий workflow `.github/workflows/ops-notify.yml` их отбрасывает:
|
|
99
|
+
оба сообщают про PR, который уже объявлен открытым, и открытие одного PR давало
|
|
100
|
+
три карточки подряд. Правило владельца от 27.07.2026 — одна новость, одна
|
|
101
|
+
карточка: **открыт → вердикт ревью (👍 / 📝) → закрыт или смёржен**. Белый список
|
|
102
|
+
живёт в общем workflow, а не в подписках проектов, потому что подписка обязана
|
|
103
|
+
лежать в вызывающем репозитории и в четырёх проектах расходится сама собой;
|
|
104
|
+
политика же должна раскатываться одним тегом.
|
|
105
|
+
|
|
106
|
+
## Новый проект
|
|
107
|
+
|
|
108
|
+
1. В Telegram: создать группу, открыть её → «Изменить» → включить **«Темы»**,
|
|
109
|
+
добавить `@mikita_ops_bot` администратором с правом «Управление темами».
|
|
110
|
+
Это единственный ручной шаг — Telegram разрешает создавать группы только
|
|
111
|
+
живому аккаунту, ботом это не сделать.
|
|
112
|
+
2. `notify setup <chat_id> maphub` — заведёт вкладки «⚙️ Ops» и «💬 Dev» и
|
|
113
|
+
напечатает готовую строку.
|
|
114
|
+
3. Вставить строку в `src/routes.ts`.
|
|
115
|
+
|
|
116
|
+
**Появились сотрудники на проекте** — просто добавь их в форум этого проекта.
|
|
117
|
+
Ничего не мигрируется и не дублируется: они видят Ops и Dev своего проекта и
|
|
118
|
+
не видят остальных.
|
|
119
|
+
|
|
120
|
+
## Эволюция схемы событий
|
|
121
|
+
|
|
122
|
+
Версии схемы нет и не будет. Сообщение живёт секунду и читается глазами —
|
|
123
|
+
версионировать нечего. Вместо этого одно правило: **новое поле у
|
|
124
|
+
существующего типа — только опциональное; обязательные поля не добавляются
|
|
125
|
+
никогда, только новый тип события**. Тогда старый вызывающий код и новый
|
|
126
|
+
пакет всегда совместимы.
|
|
127
|
+
|
|
128
|
+
## Чего в пакете нет и почему
|
|
129
|
+
|
|
130
|
+
- Очереди, брокера, демона — десятки сообщений в день, ретрай в памяти
|
|
131
|
+
процесса; доставка уведомления не стоит инфраструктуры, которую саму надо
|
|
132
|
+
мониторить.
|
|
133
|
+
- Редактирования уже отправленных сообщений — требует хранить `message_id`,
|
|
134
|
+
то есть состояние и базу.
|
|
135
|
+
- Автосоздания темы при первой отправке — сбой мог бы наплодить дублей;
|
|
136
|
+
создание — явная команда `notify setup`.
|
|
137
|
+
- Любых зависимостей, сборки, MarkdownV2, мультиязычности, троттла (троттл —
|
|
138
|
+
забота вызывающего кода, он есть в `arvent/web/src/server/alert.ts` и там
|
|
139
|
+
и остаётся: работает только внутри долгоживущего процесса, а CLI стартует
|
|
140
|
+
заново на каждый вызов).
|
|
141
|
+
|
|
142
|
+
## Разработка
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npm run typecheck # tsc --noEmit
|
|
146
|
+
npm test # node --test src/render.test.ts
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Release
|
|
150
|
+
|
|
151
|
+
Один ритуал для всех общих пакетов (канон — скилл `package-ops`):
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
release patch|minor|major # из этого каталога
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Тесты → бамп + тег → публикация из клона тега → точная версия у всех
|
|
158
|
+
потребителей с прогоном их собственных проверок, включая пин `uses:` в их
|
|
159
|
+
workflow. `release` и `propagate` живут в `mac-config/home/bin/` и доступны из
|
|
160
|
+
PATH. Карты потребителей нет: `propagate` находит их поиском по репозиториям.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `notify <type> [--flag value]...` — тонкий диспетчер. Ноль зависимостей:
|
|
4
|
+
* разбор аргументов написан руками (не yargs/commander), потому что здесь
|
|
5
|
+
* нужно ровно два вида флагов (одиночный и повторяемый `key=value`).
|
|
6
|
+
*
|
|
7
|
+
* Код возврата ВСЕГДА 0 — уведомление не имеет права уронить вызвавший его
|
|
8
|
+
* деплой или задачу. Все ошибки — только в stderr. Исключения намеренно нет
|
|
9
|
+
* (см. docs/rollout.md «чего не делаем»): сценария, где деплой должен упасть
|
|
10
|
+
* из-за неотправленного сообщения, не существует.
|
|
11
|
+
*
|
|
12
|
+
* notify deploy --project playhub --status ok --commit "msg" [--commit-url "..."] --url "..."
|
|
13
|
+
* notify job --project playhub --job "Импорт игр" --status ok --stat "добавлено=5"
|
|
14
|
+
* notify report --project playhub --title "Сводка за день" --line "Игр=1284"
|
|
15
|
+
* notify ci --project arvent --status fail --branch master --actor saz_sam
|
|
16
|
+
* notify pr --project arvent --action opened --number 142 --title "..."
|
|
17
|
+
* notify incident --project arvent --title "Redis недоступен" --detail "$ERR"
|
|
18
|
+
* notify <type> --json < payload.json # весь объект события со stdin
|
|
19
|
+
* notify setup <chat_id форума> <ключ-проекта> # создать вкладки, см. setup.ts
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { notify } from "./send.js";
|
|
23
|
+
import { setupTopic } from "./setup.js";
|
|
24
|
+
const log = (msg) => console.error(`[notify] ${msg}`);
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const command = args[0];
|
|
27
|
+
if (command === 'setup') {
|
|
28
|
+
const [, chatId, projectKey] = args;
|
|
29
|
+
if (!chatId || !projectKey) {
|
|
30
|
+
log('использование: notify setup <chat_id форума> <ключ-проекта>');
|
|
31
|
+
log(' сначала создай группу, включи в ней «Темы» и добавь бота админом');
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
await setupTopic(chatId, projectKey);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
const flags = new Map();
|
|
38
|
+
const parseErrors = [];
|
|
39
|
+
/** Флаги без значения. Всё остальное обязано его иметь. */
|
|
40
|
+
const BOOLEAN_FLAGS = new Set(['json']);
|
|
41
|
+
for (let i = 1; i < args.length; i++) {
|
|
42
|
+
const arg = args[i];
|
|
43
|
+
if (!arg.startsWith('--')) {
|
|
44
|
+
parseErrors.push(`лишний аргумент без флага: «${arg}»`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// Форма `--key=value` обязательна для значений, начинающихся с `--`
|
|
48
|
+
// (текст ошибки, кусок диффа): иначе они были бы съедены как флаги.
|
|
49
|
+
const eq = arg.indexOf('=');
|
|
50
|
+
if (eq !== -1) {
|
|
51
|
+
const key = arg.slice(2, eq);
|
|
52
|
+
flags.set(key, [...(flags.get(key) ?? []), arg.slice(eq + 1)]);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const key = arg.slice(2);
|
|
56
|
+
if (BOOLEAN_FLAGS.has(key)) {
|
|
57
|
+
flags.set(key, ['true']);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const next = args[i + 1];
|
|
61
|
+
// Раньше флаг без значения молча становился строкой 'true'. Отсюда
|
|
62
|
+
// `--url` в конце команды давал `href="true"`, Telegram отвечал 400 и
|
|
63
|
+
// ТЕРЯЛОСЬ ВСЁ сообщение, а `--status` без значения рисовал 🔴 на
|
|
64
|
+
// успешном деплое. Теперь это явная ошибка разбора.
|
|
65
|
+
if (next === undefined || next.startsWith('--')) {
|
|
66
|
+
parseErrors.push(`флаг --${key} без значения`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
i++;
|
|
70
|
+
flags.set(key, [...(flags.get(key) ?? []), next]);
|
|
71
|
+
}
|
|
72
|
+
const one = (key) => flags.get(key)?.[0];
|
|
73
|
+
// --item "текст" или --item "текст|https://ссылка"
|
|
74
|
+
const items = () => (flags.get('item') ?? []).map((raw) => {
|
|
75
|
+
const idx = raw.lastIndexOf('|');
|
|
76
|
+
return idx === -1 ? { text: raw } : { text: raw.slice(0, idx), url: raw.slice(idx + 1) };
|
|
77
|
+
});
|
|
78
|
+
const pairs = (key) => (flags.get(key) ?? []).map((s) => {
|
|
79
|
+
const idx = s.indexOf('=');
|
|
80
|
+
return idx === -1 ? [s, ''] : [s.slice(0, idx), s.slice(idx + 1)];
|
|
81
|
+
});
|
|
82
|
+
const project = () => one('project');
|
|
83
|
+
const PR_ALIASES = {
|
|
84
|
+
opened: 'opened',
|
|
85
|
+
reopened: 'opened',
|
|
86
|
+
ready_for_review: 'ready_for_review',
|
|
87
|
+
review_requested: 'review_requested',
|
|
88
|
+
review_request_removed: 'review_requested',
|
|
89
|
+
approved: 'approved',
|
|
90
|
+
changes_requested: 'changes_requested',
|
|
91
|
+
merged: 'merged',
|
|
92
|
+
closed: 'closed'
|
|
93
|
+
};
|
|
94
|
+
const ISSUE_ALIASES = {
|
|
95
|
+
opened: 'opened',
|
|
96
|
+
reopened: 'opened',
|
|
97
|
+
assigned: 'assigned',
|
|
98
|
+
closed: 'closed'
|
|
99
|
+
};
|
|
100
|
+
// Ошибка кладётся в parseErrors — тот же путь, что у остального разбора: ниже
|
|
101
|
+
// он печатает все ошибки разом и выходит ДО отправки. Значение-заглушка нужно
|
|
102
|
+
// только чтобы удовлетворить тип, до сети оно не доживёт.
|
|
103
|
+
const prAction = (raw) => {
|
|
104
|
+
const hit = PR_ALIASES[(raw ?? '').toLowerCase()];
|
|
105
|
+
if (!hit) {
|
|
106
|
+
parseErrors.push(`--action: неизвестное действие PR «${raw ?? ''}» (${Object.keys(PR_ALIASES).join(', ')})`);
|
|
107
|
+
return 'opened';
|
|
108
|
+
}
|
|
109
|
+
return hit;
|
|
110
|
+
};
|
|
111
|
+
const issueAction = (raw) => {
|
|
112
|
+
const hit = ISSUE_ALIASES[(raw ?? '').toLowerCase()];
|
|
113
|
+
if (!hit) {
|
|
114
|
+
parseErrors.push(`--action: неизвестное действие задачи «${raw ?? ''}» (${Object.keys(ISSUE_ALIASES).join(', ')})`);
|
|
115
|
+
return 'opened';
|
|
116
|
+
}
|
|
117
|
+
return hit;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Всё, что не признано успехом, считается провалом.
|
|
121
|
+
*
|
|
122
|
+
* Тут важна не строгость, а согласованность: раньше `--status success`
|
|
123
|
+
* (естественная опечатка при ручном вызове) рисовал 🔴 «упал», но `severity()`
|
|
124
|
+
* видел «не fail» и слал сообщение БЕЗ звука. Красная плашка без звука —
|
|
125
|
+
* худший исход: авария выглядит аварией, но не будит.
|
|
126
|
+
*/
|
|
127
|
+
const status = () => {
|
|
128
|
+
const raw = (one('status') ?? '').toLowerCase();
|
|
129
|
+
return raw === 'ok' || raw === 'success' || raw === 'passed' || raw === '0' ? 'ok' : 'fail';
|
|
130
|
+
};
|
|
131
|
+
let event;
|
|
132
|
+
if (flags.has('json')) {
|
|
133
|
+
try {
|
|
134
|
+
const payload = JSON.parse(readFileSync(0, 'utf-8'));
|
|
135
|
+
event = { type: command, ...payload };
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
log(`не удалось разобрать --json со stdin: ${err instanceof Error ? err.message : String(err)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
switch (command) {
|
|
143
|
+
case 'deploy':
|
|
144
|
+
event = {
|
|
145
|
+
type: 'deploy',
|
|
146
|
+
project: project(),
|
|
147
|
+
status: status(),
|
|
148
|
+
commit: one('commit'),
|
|
149
|
+
commitUrl: one('commit-url'),
|
|
150
|
+
url: one('url'),
|
|
151
|
+
target: one('target'),
|
|
152
|
+
via: one('via')
|
|
153
|
+
};
|
|
154
|
+
break;
|
|
155
|
+
case 'job':
|
|
156
|
+
event = {
|
|
157
|
+
type: 'job',
|
|
158
|
+
project: project(),
|
|
159
|
+
job: one('job') ?? '(без имени)',
|
|
160
|
+
status: status(),
|
|
161
|
+
stats: pairs('stat'),
|
|
162
|
+
items: items(),
|
|
163
|
+
note: one('note'),
|
|
164
|
+
url: one('url')
|
|
165
|
+
};
|
|
166
|
+
break;
|
|
167
|
+
case 'report':
|
|
168
|
+
event = {
|
|
169
|
+
type: 'report',
|
|
170
|
+
project: project(),
|
|
171
|
+
title: one('title') ?? '(без заголовка)',
|
|
172
|
+
period: one('period'),
|
|
173
|
+
lines: pairs('line'),
|
|
174
|
+
items: items(),
|
|
175
|
+
url: one('url')
|
|
176
|
+
};
|
|
177
|
+
break;
|
|
178
|
+
case 'ci':
|
|
179
|
+
event = {
|
|
180
|
+
type: 'ci',
|
|
181
|
+
project: project(),
|
|
182
|
+
status: status(),
|
|
183
|
+
branch: one('branch'),
|
|
184
|
+
commit: one('commit'),
|
|
185
|
+
actor: one('actor'),
|
|
186
|
+
url: one('url')
|
|
187
|
+
};
|
|
188
|
+
break;
|
|
189
|
+
case 'pr':
|
|
190
|
+
event = {
|
|
191
|
+
type: 'pr',
|
|
192
|
+
project: project(),
|
|
193
|
+
action: prAction(one('action')),
|
|
194
|
+
number: Number(one('number')),
|
|
195
|
+
title: one('title') ?? '(без заголовка)',
|
|
196
|
+
author: one('author'),
|
|
197
|
+
reviewer: one('reviewer'),
|
|
198
|
+
url: one('url')
|
|
199
|
+
};
|
|
200
|
+
break;
|
|
201
|
+
case 'issue':
|
|
202
|
+
event = {
|
|
203
|
+
type: 'issue',
|
|
204
|
+
project: project(),
|
|
205
|
+
action: issueAction(one('action')),
|
|
206
|
+
number: Number(one('number')),
|
|
207
|
+
title: one('title') ?? '(без заголовка)',
|
|
208
|
+
author: one('author'),
|
|
209
|
+
assignee: one('assignee'),
|
|
210
|
+
url: one('url')
|
|
211
|
+
};
|
|
212
|
+
break;
|
|
213
|
+
case 'incident':
|
|
214
|
+
event = {
|
|
215
|
+
type: 'incident',
|
|
216
|
+
project: project(),
|
|
217
|
+
title: one('title') ?? '(без заголовка)',
|
|
218
|
+
detail: one('detail'),
|
|
219
|
+
url: one('url')
|
|
220
|
+
};
|
|
221
|
+
break;
|
|
222
|
+
case 'heartbeat_miss':
|
|
223
|
+
event = {
|
|
224
|
+
type: 'heartbeat_miss',
|
|
225
|
+
project: project(),
|
|
226
|
+
job: one('job') ?? '(без имени)',
|
|
227
|
+
lastSeen: one('last-seen'),
|
|
228
|
+
expected: one('expected')
|
|
229
|
+
};
|
|
230
|
+
break;
|
|
231
|
+
default:
|
|
232
|
+
log(`неизвестный тип события: ${command ?? '(не указан)'}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Ошибки разбора — до отправки: лучше внятно сказать, что не так с командой,
|
|
236
|
+
// чем прислать сообщение с «true» вместо ссылки или 🔴 на успешном деплое.
|
|
237
|
+
if (parseErrors.length > 0) {
|
|
238
|
+
for (const err of parseErrors) {
|
|
239
|
+
log(err);
|
|
240
|
+
}
|
|
241
|
+
log('событие не отправлено — исправь команду');
|
|
242
|
+
process.exit(0);
|
|
243
|
+
}
|
|
244
|
+
if (event) {
|
|
245
|
+
// Ловим ВСЁ: уведомление не имеет права уронить деплой или крон, который
|
|
246
|
+
// его вызвал. В bash с `set -e` (или в `trap ... ERR`) ненулевой код здесь
|
|
247
|
+
// завалил бы саму задачу — ровно то, чего пакет обязан не делать.
|
|
248
|
+
try {
|
|
249
|
+
log(await notify(event));
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
log(`не отправлено: ${err instanceof Error ? err.message : String(err)}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
process.exit(0);
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Каталог событий — единственная точка входа для отправки. `notify()` (см.
|
|
3
|
+
* `send.ts`) принимает ТОЛЬКО значения этого типа: свободного текста в API
|
|
4
|
+
* нет, значит «своё» сообщение технически не написать.
|
|
5
|
+
*
|
|
6
|
+
* Правило эволюции схемы (версии нет и не будет — сообщение живёт секунду и
|
|
7
|
+
* читается глазами, версионировать нечего):
|
|
8
|
+
* - новое поле у СУЩЕСТВУЮЩЕГО типа добавляется ТОЛЬКО опциональным;
|
|
9
|
+
* - обязательные поля не добавляются никогда — только новый тип события.
|
|
10
|
+
* Тогда старый вызывающий код и новый пакет совместимы в обе стороны.
|
|
11
|
+
*/
|
|
12
|
+
export type Project = 'playhub' | 'one-q' | 'arvent' | 'game-publisher';
|
|
13
|
+
/**
|
|
14
|
+
* Позиция списка внутри сообщения: задача из дайджеста, упавшая проверка,
|
|
15
|
+
* замечание. `url` необязателен — тогда рендерится просто строкой.
|
|
16
|
+
*/
|
|
17
|
+
export type Item = {
|
|
18
|
+
text: string;
|
|
19
|
+
url?: string;
|
|
20
|
+
};
|
|
21
|
+
export type NotifyEvent =
|
|
22
|
+
/** Выкатка кода на сервер. */
|
|
23
|
+
{
|
|
24
|
+
type: 'deploy';
|
|
25
|
+
project: Project;
|
|
26
|
+
status: 'ok' | 'fail';
|
|
27
|
+
commit?: string;
|
|
28
|
+
/** Ссылка на коммит — строка «коммит» становится кликабельной. */
|
|
29
|
+
commitUrl?: string;
|
|
30
|
+
url?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Куда выкатили. Заполнять ТОЛЬКО когда окружений больше одного: у сайтов
|
|
33
|
+
* с единственным продом «куда: прод» — строка, которую читают глазами и
|
|
34
|
+
* ничего из неё не узнают.
|
|
35
|
+
*/
|
|
36
|
+
target?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Откуда запустили: «вручную с Mac», «GitHub Actions». Вот это как раз
|
|
39
|
+
* новость — путей выкатки два, они дают разные последствия (ручной идёт
|
|
40
|
+
* с ноутбука и переменные берёт из локального .env), и по карточке видно,
|
|
41
|
+
* какой сработал.
|
|
42
|
+
*/
|
|
43
|
+
via?: string;
|
|
44
|
+
}
|
|
45
|
+
/** Регулярная задача по расписанию: импорт игр, бэкап БД, валидатор. */
|
|
46
|
+
| {
|
|
47
|
+
type: 'job';
|
|
48
|
+
project: Project;
|
|
49
|
+
job: string;
|
|
50
|
+
status: 'ok' | 'fail';
|
|
51
|
+
stats?: Array<[label: string, value: string | number]>;
|
|
52
|
+
/** Детали: что именно упало, замечания прогона. */
|
|
53
|
+
items?: Item[];
|
|
54
|
+
note?: string;
|
|
55
|
+
url?: string;
|
|
56
|
+
}
|
|
57
|
+
/** Сводка с цифрами: дневной отчёт, дайджест аналитики. */
|
|
58
|
+
| {
|
|
59
|
+
type: 'report';
|
|
60
|
+
project: Project;
|
|
61
|
+
title: string;
|
|
62
|
+
period?: string;
|
|
63
|
+
lines: Array<[label: string, value: string | number]>;
|
|
64
|
+
/**
|
|
65
|
+
* Список позиций со ссылками — для дайджестов задач, где ценность в
|
|
66
|
+
* самих названиях, а не в цифре. Рендерятся отдельным блоком после
|
|
67
|
+
* `lines`.
|
|
68
|
+
*/
|
|
69
|
+
items?: Item[];
|
|
70
|
+
url?: string;
|
|
71
|
+
}
|
|
72
|
+
/** Итог CI на основной ветке. */
|
|
73
|
+
| {
|
|
74
|
+
type: 'ci';
|
|
75
|
+
project: Project;
|
|
76
|
+
status: 'ok' | 'fail';
|
|
77
|
+
branch?: string;
|
|
78
|
+
commit?: string;
|
|
79
|
+
actor?: string;
|
|
80
|
+
url?: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Событие пул-реквеста. Виды покрывают весь путь PR, потому что владелец
|
|
84
|
+
* следит за работой команды по вкладке Ops, а не по почте: почта приходит
|
|
85
|
+
* только когда тебя позвали лично, и половина событий в неё не попадает.
|
|
86
|
+
*/
|
|
87
|
+
| {
|
|
88
|
+
type: 'pr';
|
|
89
|
+
project: Project;
|
|
90
|
+
action: 'opened' | 'ready_for_review' | 'review_requested' | 'approved' | 'changes_requested' | 'merged' | 'closed';
|
|
91
|
+
number: number;
|
|
92
|
+
title: string;
|
|
93
|
+
author?: string;
|
|
94
|
+
reviewer?: string;
|
|
95
|
+
url?: string;
|
|
96
|
+
}
|
|
97
|
+
/** Событие задачи: заведена, взята в работу, закрыта. */
|
|
98
|
+
| {
|
|
99
|
+
type: 'issue';
|
|
100
|
+
project: Project;
|
|
101
|
+
action: 'opened' | 'assigned' | 'closed';
|
|
102
|
+
number: number;
|
|
103
|
+
title: string;
|
|
104
|
+
author?: string;
|
|
105
|
+
assignee?: string;
|
|
106
|
+
url?: string;
|
|
107
|
+
}
|
|
108
|
+
/** Приложение сломалось прямо сейчас (рантайм-алерт). */
|
|
109
|
+
| {
|
|
110
|
+
type: 'incident';
|
|
111
|
+
project: Project;
|
|
112
|
+
title: string;
|
|
113
|
+
detail?: string;
|
|
114
|
+
url?: string;
|
|
115
|
+
}
|
|
116
|
+
/** Задача не отметилась вовремя — сторож молчания (heartbeat). */
|
|
117
|
+
| {
|
|
118
|
+
type: 'heartbeat_miss';
|
|
119
|
+
project: Project;
|
|
120
|
+
job: string;
|
|
121
|
+
lastSeen?: string;
|
|
122
|
+
expected?: string;
|
|
123
|
+
};
|
|
124
|
+
export type EventType = NotifyEvent['type'];
|
|
125
|
+
/** Красное = со звуком и с дублем в тему инцидентов. Всё остальное — тихо. */
|
|
126
|
+
export declare const severity: (e: NotifyEvent) => "info" | "error";
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Каталог событий — единственная точка входа для отправки. `notify()` (см.
|
|
3
|
+
* `send.ts`) принимает ТОЛЬКО значения этого типа: свободного текста в API
|
|
4
|
+
* нет, значит «своё» сообщение технически не написать.
|
|
5
|
+
*
|
|
6
|
+
* Правило эволюции схемы (версии нет и не будет — сообщение живёт секунду и
|
|
7
|
+
* читается глазами, версионировать нечего):
|
|
8
|
+
* - новое поле у СУЩЕСТВУЮЩЕГО типа добавляется ТОЛЬКО опциональным;
|
|
9
|
+
* - обязательные поля не добавляются никогда — только новый тип события.
|
|
10
|
+
* Тогда старый вызывающий код и новый пакет совместимы в обе стороны.
|
|
11
|
+
*/
|
|
12
|
+
/** Красное = со звуком и с дублем в тему инцидентов. Всё остальное — тихо. */
|
|
13
|
+
export const severity = (e) => {
|
|
14
|
+
if (e.type === 'incident' || e.type === 'heartbeat_miss') {
|
|
15
|
+
return 'error';
|
|
16
|
+
}
|
|
17
|
+
if ('status' in e && e.status === 'fail') {
|
|
18
|
+
return 'error';
|
|
19
|
+
}
|
|
20
|
+
return 'info';
|
|
21
|
+
};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Один рендерер на тип события, все по одному каркасу:
|
|
3
|
+
*
|
|
4
|
+
* эмодзи Заголовок · проект
|
|
5
|
+
* ключ: значение
|
|
6
|
+
* ключ: значение
|
|
7
|
+
* <a href="…">Ссылка</a>
|
|
8
|
+
*
|
|
9
|
+
* Проект указывается ВСЕГДА, даже в теме самого проекта — в теме
|
|
10
|
+
* `🔴 incidents` сообщения четырёх проектов лежат вперемешку, и формат
|
|
11
|
+
* должен быть один и тот же независимо от того, куда сообщение попало.
|
|
12
|
+
*/
|
|
13
|
+
import type { NotifyEvent } from './events.ts';
|
|
14
|
+
/** Экранируется ВСЁ, что пришло снаружи — теги ставит только шаблон. */
|
|
15
|
+
export declare const esc: (v: unknown) => string;
|
|
16
|
+
/**
|
|
17
|
+
* Telegram режет сообщение на 4096 символах — режем сами, по возможности по
|
|
18
|
+
* границе строки.
|
|
19
|
+
*
|
|
20
|
+
* Два подвоха, оба приводили к ТИХОЙ потере сообщения:
|
|
21
|
+
* 1. Резать строго по последнему `\n` нельзя: если длинный кусок идёт одной
|
|
22
|
+
* строкой (стектрейс, вывод команды — самый частый `detail` у инцидента),
|
|
23
|
+
* последний перевод строки стоит ПЕРЕД ним, и содержимое выбрасывалось
|
|
24
|
+
* целиком — приходил заголовок без единого факта о поломке.
|
|
25
|
+
* 2. Резать посреди HTML-тега или сущности тоже нельзя: Telegram отвечает
|
|
26
|
+
* `400 can't parse entities`, а 4xx мы считаем постоянной ошибкой и не
|
|
27
|
+
* повторяем — сообщение исчезало совсем.
|
|
28
|
+
*/
|
|
29
|
+
export declare const clampMessage: (text: string, limit?: number) => string;
|
|
30
|
+
/** Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram. */
|
|
31
|
+
export declare const render: (e: NotifyEvent) => string;
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/** Экранируется ВСЁ, что пришло снаружи — теги ставит только шаблон. */
|
|
2
|
+
export const esc = (v) => String(v ?? '')
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"');
|
|
7
|
+
/**
|
|
8
|
+
* Telegram режет сообщение на 4096 символах — режем сами, по возможности по
|
|
9
|
+
* границе строки.
|
|
10
|
+
*
|
|
11
|
+
* Два подвоха, оба приводили к ТИХОЙ потере сообщения:
|
|
12
|
+
* 1. Резать строго по последнему `\n` нельзя: если длинный кусок идёт одной
|
|
13
|
+
* строкой (стектрейс, вывод команды — самый частый `detail` у инцидента),
|
|
14
|
+
* последний перевод строки стоит ПЕРЕД ним, и содержимое выбрасывалось
|
|
15
|
+
* целиком — приходил заголовок без единого факта о поломке.
|
|
16
|
+
* 2. Резать посреди HTML-тега или сущности тоже нельзя: Telegram отвечает
|
|
17
|
+
* `400 can't parse entities`, а 4xx мы считаем постоянной ошибкой и не
|
|
18
|
+
* повторяем — сообщение исчезало совсем.
|
|
19
|
+
*/
|
|
20
|
+
export const clampMessage = (text, limit = 4000) => {
|
|
21
|
+
if (text.length <= limit) {
|
|
22
|
+
return text;
|
|
23
|
+
}
|
|
24
|
+
const cut = text.slice(0, limit);
|
|
25
|
+
const lastBreak = cut.lastIndexOf('\n');
|
|
26
|
+
// По границе строки — только если так остаётся большая часть содержимого.
|
|
27
|
+
let end = lastBreak > limit * 0.6 ? lastBreak : limit;
|
|
28
|
+
// Не обрываемся внутри `<...>` и внутри `&...;` — иначе разметка ломается.
|
|
29
|
+
const openTag = cut.lastIndexOf('<', end - 1);
|
|
30
|
+
if (openTag !== -1 && cut.indexOf('>', openTag) === -1) {
|
|
31
|
+
end = openTag;
|
|
32
|
+
}
|
|
33
|
+
const amp = cut.lastIndexOf('&', end - 1);
|
|
34
|
+
if (amp !== -1 && end - amp <= 10 && cut.indexOf(';', amp) === -1) {
|
|
35
|
+
end = amp;
|
|
36
|
+
}
|
|
37
|
+
const body = cut.slice(0, end);
|
|
38
|
+
// Кламп мог отрезать закрывающие теги — добираем их, чтобы разметка сошлась.
|
|
39
|
+
const tail = ['b', 'a', 'i', 'code']
|
|
40
|
+
.filter((t) => {
|
|
41
|
+
const opened = (body.match(new RegExp(`<${t}[ >]`, 'g')) ?? []).length;
|
|
42
|
+
const closed = (body.match(new RegExp(`</${t}>`, 'g')) ?? []).length;
|
|
43
|
+
return opened > closed;
|
|
44
|
+
})
|
|
45
|
+
.map((t) => `</${t}>`)
|
|
46
|
+
.join('');
|
|
47
|
+
return `${body}${tail}\n…`;
|
|
48
|
+
};
|
|
49
|
+
const header = (icon, title, project) => `${icon} <b>${esc(title)}</b> · ${esc(project)}`;
|
|
50
|
+
// Значение — жирным: канон формата (bot-message-formatting-canon) — иконка-лид,
|
|
51
|
+
// факты построчно, значения выделены; метка остаётся обычной, чтобы глаз
|
|
52
|
+
// цеплялся за содержимое, а не за служебное слово.
|
|
53
|
+
const kv = (label, value) => value === undefined || value === '' ? null : `${esc(label)}: <b>${esc(value)}</b>`;
|
|
54
|
+
const link = (url, label) => url ? `<a href="${esc(url)}">${esc(label)}</a>` : null;
|
|
55
|
+
const join = (parts) => parts.filter((p) => p !== null).join('\n');
|
|
56
|
+
/** Список позиций — общий для `job` и `report`, чтобы они не разъехались. */
|
|
57
|
+
const bullets = (items) => (items ?? []).map((it) => (it.url ? `• <a href="${esc(it.url)}">${esc(it.text)}</a>` : `• ${esc(it.text)}`));
|
|
58
|
+
const renderDeploy = (e) => {
|
|
59
|
+
const icon = e.status === 'ok' ? '✅' : '🔴';
|
|
60
|
+
const title = e.status === 'ok' ? 'Деплой завершён' : 'Деплой упал';
|
|
61
|
+
// Коммит со ссылкой — кликабельная строка вместо голого текста; жалоба
|
|
62
|
+
// владельца на некликабельные дайджесты распространяется и сюда.
|
|
63
|
+
const commitLine = e.commit
|
|
64
|
+
? e.commitUrl
|
|
65
|
+
? `коммит: <a href="${esc(e.commitUrl)}"><b>${esc(e.commit)}</b></a>`
|
|
66
|
+
: kv('коммит', e.commit)
|
|
67
|
+
: null;
|
|
68
|
+
return join([
|
|
69
|
+
header(icon, title, e.project),
|
|
70
|
+
commitLine,
|
|
71
|
+
kv('откуда', e.via),
|
|
72
|
+
kv('куда', e.target),
|
|
73
|
+
link(e.url, 'Открыть логи')
|
|
74
|
+
]);
|
|
75
|
+
};
|
|
76
|
+
const renderJob = (e) => {
|
|
77
|
+
const icon = e.status === 'ok' ? '✅' : '🔴';
|
|
78
|
+
const items = bullets(e.items);
|
|
79
|
+
return join([
|
|
80
|
+
header(icon, e.job, e.project),
|
|
81
|
+
...(e.stats ?? []).map(([label, value]) => kv(label, value)),
|
|
82
|
+
items.length > 0 ? '' : null,
|
|
83
|
+
...items,
|
|
84
|
+
kv('примечание', e.note),
|
|
85
|
+
link(e.url, 'Подробнее')
|
|
86
|
+
]);
|
|
87
|
+
};
|
|
88
|
+
const renderReport = (e) => {
|
|
89
|
+
const items = bullets(e.items);
|
|
90
|
+
return join([
|
|
91
|
+
header('📊', e.title, e.project),
|
|
92
|
+
e.period ? esc(e.period) : null,
|
|
93
|
+
e.period ? '' : null,
|
|
94
|
+
...e.lines.map(([label, value]) => kv(label, value)),
|
|
95
|
+
items.length > 0 ? '' : null,
|
|
96
|
+
...items,
|
|
97
|
+
link(e.url, 'Открыть отчёт')
|
|
98
|
+
]);
|
|
99
|
+
};
|
|
100
|
+
const renderCi = (e) => {
|
|
101
|
+
const icon = e.status === 'ok' ? '✅' : '🔴';
|
|
102
|
+
const title = e.status === 'ok' ? 'CI зелёный' : 'CI упал';
|
|
103
|
+
return join([
|
|
104
|
+
header(icon, title, e.project),
|
|
105
|
+
kv('ветка', e.branch),
|
|
106
|
+
kv('коммит', e.commit),
|
|
107
|
+
kv('автор', e.actor),
|
|
108
|
+
link(e.url, 'Открыть логи')
|
|
109
|
+
]);
|
|
110
|
+
};
|
|
111
|
+
// Значок у каждого вида свой: в ленте Ops событие узнаётся по нему до чтения
|
|
112
|
+
// текста. Дублировать значок между видами нельзя — легенда закреплена в теме
|
|
113
|
+
// и обещает однозначность.
|
|
114
|
+
const PR_TITLES = {
|
|
115
|
+
opened: { icon: '🔀', verb: 'открыт' },
|
|
116
|
+
ready_for_review: { icon: '📤', verb: 'готов к ревью' },
|
|
117
|
+
review_requested: { icon: '👁', verb: 'ждёт ревью' },
|
|
118
|
+
approved: { icon: '👍', verb: 'ревью пройдено' },
|
|
119
|
+
changes_requested: { icon: '📝', verb: 'запрошены правки' },
|
|
120
|
+
merged: { icon: '✅', verb: 'смёржен' },
|
|
121
|
+
closed: { icon: '⛔', verb: 'закрыт без слияния' }
|
|
122
|
+
};
|
|
123
|
+
const ISSUE_TITLES = {
|
|
124
|
+
opened: { icon: '🆕', verb: 'заведена' },
|
|
125
|
+
assigned: { icon: '🙋', verb: 'взята в работу' },
|
|
126
|
+
closed: { icon: '☑️', verb: 'закрыта' }
|
|
127
|
+
};
|
|
128
|
+
const renderPr = (e) => {
|
|
129
|
+
const { icon, verb } = PR_TITLES[e.action];
|
|
130
|
+
return join([
|
|
131
|
+
header(icon, `PR #${e.number} ${verb}`, e.project),
|
|
132
|
+
esc(e.title),
|
|
133
|
+
kv('автор', e.author),
|
|
134
|
+
kv('ревьюер', e.reviewer),
|
|
135
|
+
link(e.url, 'Открыть PR')
|
|
136
|
+
]);
|
|
137
|
+
};
|
|
138
|
+
const renderIssue = (e) => {
|
|
139
|
+
const { icon, verb } = ISSUE_TITLES[e.action];
|
|
140
|
+
return join([
|
|
141
|
+
header(icon, `Задача #${e.number} ${verb}`, e.project),
|
|
142
|
+
esc(e.title),
|
|
143
|
+
kv('автор', e.author),
|
|
144
|
+
kv('исполнитель', e.assignee),
|
|
145
|
+
link(e.url, 'Открыть задачу')
|
|
146
|
+
]);
|
|
147
|
+
};
|
|
148
|
+
const renderIncident = (e) => join([header('🚨', 'Инцидент', e.project), esc(e.title), e.detail ? esc(e.detail) : null, link(e.url, 'Подробнее')]);
|
|
149
|
+
const renderHeartbeatMiss = (e) => join([
|
|
150
|
+
header('🔴', `Не отметилась: ${e.job}`, e.project),
|
|
151
|
+
kv('последний раз', e.lastSeen),
|
|
152
|
+
kv('ожидалось', e.expected)
|
|
153
|
+
]);
|
|
154
|
+
const RENDERERS = {
|
|
155
|
+
deploy: renderDeploy,
|
|
156
|
+
job: renderJob,
|
|
157
|
+
report: renderReport,
|
|
158
|
+
ci: renderCi,
|
|
159
|
+
pr: renderPr,
|
|
160
|
+
issue: renderIssue,
|
|
161
|
+
incident: renderIncident,
|
|
162
|
+
heartbeat_miss: renderHeartbeatMiss
|
|
163
|
+
};
|
|
164
|
+
/** Рендерит событие в готовый HTML-текст, обрезанный под лимит Telegram. */
|
|
165
|
+
export const render = (e) => {
|
|
166
|
+
const renderer = RENDERERS[e.type];
|
|
167
|
+
// Прикрывает путь `--json` и вызовы из JS без типов: там `type` — обычная
|
|
168
|
+
// строка, и неизвестное значение роняло процесс через `renderer is not a
|
|
169
|
+
// function`. Падать из-за уведомления нельзя.
|
|
170
|
+
if (typeof renderer !== 'function') {
|
|
171
|
+
throw new Error(`неизвестный тип события: ${String(e.type)}`);
|
|
172
|
+
}
|
|
173
|
+
return clampMessage(renderer(e));
|
|
174
|
+
};
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Маршрутизация «событие → куда слать». ЕДИНСТВЕННОЕ место, которое трогает
|
|
3
|
+
* новый проект: добавить строку в `ROUTES` — ни нового бота, ни нового
|
|
4
|
+
* секрета, ни правок в проектах.
|
|
5
|
+
*
|
|
6
|
+
* Схема: ФОРУМ НА ПРОЕКТ, внутри вкладки «⚙️ Ops» (уведомления роботов) и
|
|
7
|
+
* «💬 Dev» (живой чат людей).
|
|
8
|
+
*
|
|
9
|
+
* Почему не один общий форум с темой на проект — так было в первой редакции
|
|
10
|
+
* и это оказалось ошибкой: Telegram не умеет закрывать отдельную тему от
|
|
11
|
+
* участника, кто в группе — видит ВСЕ вкладки. Значит сотрудников туда не
|
|
12
|
+
* пустить, им нужен свой чат, и один проект начинает жить в двух местах
|
|
13
|
+
* сразу («тема у владельца» + «канал у команды»). Форум на проект убирает
|
|
14
|
+
* дублирование: сотрудник добавляется в форум своего проекта и чужих не
|
|
15
|
+
* видит, а схема одна для всех — появились люди на новом проекте, просто
|
|
16
|
+
* добавь их туда же.
|
|
17
|
+
*
|
|
18
|
+
* Id чатов и тем — не секрет: без токена бота они бесполезны. Поэтому живут
|
|
19
|
+
* в коде, а не в переменных окружения (иначе «добавить проект» = правка в
|
|
20
|
+
* трёх местах). Секрет ровно один — `OPS_BOT_TOKEN`.
|
|
21
|
+
*/
|
|
22
|
+
import type { NotifyEvent, Project } from './events.ts';
|
|
23
|
+
type Forum = {
|
|
24
|
+
/** Id форума-супергруппы проекта. */
|
|
25
|
+
chat: string;
|
|
26
|
+
/** Вкладка «⚙️ Ops» — сюда пишут роботы. */
|
|
27
|
+
ops: number;
|
|
28
|
+
/** Вкладка «💬 Dev» — люди. Бот сюда не пишет; поле держим для полноты. */
|
|
29
|
+
dev: number;
|
|
30
|
+
};
|
|
31
|
+
export declare const ROUTES: Record<Project, Forum>;
|
|
32
|
+
export type Target = {
|
|
33
|
+
chat: string;
|
|
34
|
+
thread?: number;
|
|
35
|
+
silent: boolean;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Куда уходит событие. Всё — во вкладку «Ops» своего проекта; красное
|
|
39
|
+
* приходит туда же, но со звуком.
|
|
40
|
+
*
|
|
41
|
+
* Отдельной темы «инциденты» больше нет: она имела смысл, пока форум был
|
|
42
|
+
* общий на все проекты. Теперь у каждого проекта своя группа, и авария
|
|
43
|
+
* видна в его же ленте — сводить их в одно место незачем, а звук и так
|
|
44
|
+
* отличает аварию от рядового сообщения.
|
|
45
|
+
*/
|
|
46
|
+
export declare const targets: (e: NotifyEvent) => Target[];
|
|
47
|
+
export {};
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { severity } from "./events.js";
|
|
2
|
+
export const ROUTES = {
|
|
3
|
+
// ops/dev = 22/23, а не 3/4: старые вкладки удалили вручную 27.07.2026, и
|
|
4
|
+
// вместе с темой Telegram сносит её сообщения. Пересозданная тема получает
|
|
5
|
+
// НОВЫЙ id — id темы это id её первого сообщения, он не переиспользуется.
|
|
6
|
+
arvent: { chat: '-1004299939100', ops: 22, dev: 23 },
|
|
7
|
+
playhub: { chat: '-1004418379613', ops: 3, dev: 4 },
|
|
8
|
+
'game-publisher': { chat: '-1004292453693', ops: 3, dev: 4 },
|
|
9
|
+
'one-q': { chat: '-1004466909784', ops: 3, dev: 4 }
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Куда уходит событие. Всё — во вкладку «Ops» своего проекта; красное
|
|
13
|
+
* приходит туда же, но со звуком.
|
|
14
|
+
*
|
|
15
|
+
* Отдельной темы «инциденты» больше нет: она имела смысл, пока форум был
|
|
16
|
+
* общий на все проекты. Теперь у каждого проекта своя группа, и авария
|
|
17
|
+
* видна в его же ленте — сводить их в одно место незачем, а звук и так
|
|
18
|
+
* отличает аварию от рядового сообщения.
|
|
19
|
+
*/
|
|
20
|
+
export const targets = (e) => {
|
|
21
|
+
const forum = ROUTES[e.project];
|
|
22
|
+
// Неизвестный проект — опечатка в `--project` или проект, забытый в ROUTES.
|
|
23
|
+
// Возвращаем пустой список, а не падаем: уведомление не имеет права уронить
|
|
24
|
+
// вызвавший его деплой или крон (в bash с `set -e` падение было бы фатальным).
|
|
25
|
+
if (!forum) {
|
|
26
|
+
console.error(`[notify] неизвестный проект «${e.project}» — известны: ${Object.keys(ROUTES).join(', ')}`);
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return [{ chat: forum.chat, thread: forum.ops, silent: severity(e) === 'info' }];
|
|
30
|
+
};
|
package/dist/send.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { NotifyEvent, Project } from './events.ts';
|
|
2
|
+
export type SendResult = 'sent' | 'skipped' | 'failed';
|
|
3
|
+
/**
|
|
4
|
+
* Отправляет событие во все его цели (тема проекта + при необходимости
|
|
5
|
+
* `incidents` + чат команды). Цели идут последовательно; провал одной не
|
|
6
|
+
* отменяет остальные. `'sent'`, если хотя бы одна цель получила сообщение.
|
|
7
|
+
*/
|
|
8
|
+
export declare const notify: (e: NotifyEvent) => Promise<SendResult>;
|
|
9
|
+
/**
|
|
10
|
+
* Готовый HTML во вкладку «Ops» проекта — ТОЛЬКО для дневных отчётов.
|
|
11
|
+
*
|
|
12
|
+
* Зачем исключение из правила «свободного текста в API нет». Отчёт — это не
|
|
13
|
+
* событие: в нём плотная строка вроде
|
|
14
|
+
* `🎮 1284 игр (🍎 412 iOS +3 · 🤖 890 Android +5) | 📈 +240 запусков`,
|
|
15
|
+
* и разложить её в `label=value` можно только испортив. Но транспорт у отчёта
|
|
16
|
+
* ТОТ ЖЕ: ретраи, 429, таймауты, curl-фолбэк, номер вкладки. Пока его копировали
|
|
17
|
+
* в каждый скрипт, один и тот же баг с дублями на таймауте пришлось чинить
|
|
18
|
+
* дважды — в пакете и в game-publisher (27.07.2026).
|
|
19
|
+
*
|
|
20
|
+
* Граница: формат событий по-прежнему задаёт только пакет, «своё» уведомление
|
|
21
|
+
* о деплое или упавшей задаче написать нельзя. Здесь стандартизирован транспорт,
|
|
22
|
+
* а не формат — и в этом весь смысл.
|
|
23
|
+
*
|
|
24
|
+
* Всегда беззвучно: отчёт читают утром, а не по звонку.
|
|
25
|
+
*/
|
|
26
|
+
export declare const sendReport: (project: Project, html: string) => Promise<SendResult>;
|
package/dist/send.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Транспорт. Переносит проверенный на проде код из
|
|
3
|
+
* game-publisher/scripts/lib/telegram.ts (fetch → curl-фолбэк через stdin,
|
|
4
|
+
* `.trim()` токена) и добавляет то, чего там не было: несколько целей за
|
|
5
|
+
* вызов, `message_thread_id`, повтор на HTTP 429 с уважением `retry_after`,
|
|
6
|
+
* повтор на 5xx, отказ без повтора на прочих 4xx.
|
|
7
|
+
*
|
|
8
|
+
* Токен — ТОЛЬКО из `process.env.OPS_BOT_TOKEN`, с `.trim()`: перевод
|
|
9
|
+
* строки в токене (частая находка при копипасте) заставляет curl разобрать
|
|
10
|
+
* конфиг как две директивы и утащить хвост токена в stderr прогона.
|
|
11
|
+
*
|
|
12
|
+
* Нет токена → 'skipped', не исключение: уведомление не имеет права уронить
|
|
13
|
+
* деплой или регулярную задачу, которая его вызвала.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { clampMessage, render } from "./render.js";
|
|
17
|
+
import { ROUTES, targets } from "./routes.js";
|
|
18
|
+
const log = (msg) => {
|
|
19
|
+
// stderr, не stdout — stdout зарезервирован под возможный машинный вывод CLI.
|
|
20
|
+
console.error(`[notify] ${msg}`);
|
|
21
|
+
};
|
|
22
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
const buildBody = (target, text) => JSON.stringify({
|
|
24
|
+
chat_id: target.chat,
|
|
25
|
+
...(target.thread ? { message_thread_id: target.thread } : {}),
|
|
26
|
+
text,
|
|
27
|
+
parse_mode: 'HTML',
|
|
28
|
+
disable_web_page_preview: true,
|
|
29
|
+
disable_notification: target.silent
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Запасной путь: curl — другой стек TLS/DNS, выручает там, где fetch/undici
|
|
33
|
+
* не маршрутизирует. URL с токеном уходит файлом конфига через stdin, а не
|
|
34
|
+
* аргументом: в argv его видно любому пользователю сервера через `ps aux`.
|
|
35
|
+
* stderr — в 'pipe', а не наследуется: сообщение об ошибке curl может
|
|
36
|
+
* содержать кусок URL с токеном, в лог прогона он попадать не должен.
|
|
37
|
+
*/
|
|
38
|
+
const sendViaCurl = (token, target, text) => {
|
|
39
|
+
const config = [
|
|
40
|
+
`url = "https://api.telegram.org/bot${token}/sendMessage"`,
|
|
41
|
+
'request = "POST"',
|
|
42
|
+
'header = "Content-Type: application/json"',
|
|
43
|
+
`data = ${JSON.stringify(buildBody(target, text))}`,
|
|
44
|
+
'max-time = 15',
|
|
45
|
+
'silent',
|
|
46
|
+
'fail'
|
|
47
|
+
].join('\n');
|
|
48
|
+
try {
|
|
49
|
+
execFileSync('curl', ['--config', '-'], {
|
|
50
|
+
input: config,
|
|
51
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
52
|
+
});
|
|
53
|
+
return 'ok';
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
// 28 — собственный таймаут curl (`max-time` выше). Как и таймаут fetch, он
|
|
57
|
+
// означает «ответа нет», а не «не доставлено»: повтор положил бы в чат
|
|
58
|
+
// вторую копию. Всё остальное (отказ соединения, 4xx с `fail`) повторить
|
|
59
|
+
// безопасно.
|
|
60
|
+
return err.status === 28 ? 'fail' : 'retry';
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const attempt = async (token, target, text) => {
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'Content-Type': 'application/json' },
|
|
68
|
+
body: buildBody(target, text),
|
|
69
|
+
signal: AbortSignal.timeout(10_000)
|
|
70
|
+
});
|
|
71
|
+
if (res.ok) {
|
|
72
|
+
return { outcome: 'ok' };
|
|
73
|
+
}
|
|
74
|
+
if (res.status === 429) {
|
|
75
|
+
const body = (await res.json().catch(() => null));
|
|
76
|
+
const retryAfter = typeof body?.parameters?.retry_after === 'number' ? body.parameters.retry_after : 5;
|
|
77
|
+
return { outcome: 'retry', waitMs: Math.min(retryAfter, 60) * 1000 };
|
|
78
|
+
}
|
|
79
|
+
if (res.status >= 500) {
|
|
80
|
+
return { outcome: 'retry', waitMs: 1000 };
|
|
81
|
+
}
|
|
82
|
+
// 4xx кроме 429 — постоянная ошибка (не тот thread, бот не админ,
|
|
83
|
+
// неверный chat_id). Повтор её не исправит.
|
|
84
|
+
//
|
|
85
|
+
// Причину обязательно вытаскиваем: Telegram кладёт её в `description`
|
|
86
|
+
// («message thread not found», «can't parse entities»), и без неё понять,
|
|
87
|
+
// почему уведомления пропали, невозможно — а разбираться будет не
|
|
88
|
+
// разработчик, а владелец.
|
|
89
|
+
const detail = (await res.json().catch(() => null));
|
|
90
|
+
log(`HTTP ${res.status}: ${detail?.description ?? 'без описания'} — не повторяем, ошибка постоянная`);
|
|
91
|
+
return { outcome: 'fail' };
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
// Таймаут — НЕ то же самое, что «не доставлено»: запрос мог дойти, а ответ
|
|
95
|
+
// не успеть вернуться. Повтор (хоть curl-ом, хоть следующей попыткой) кладёт
|
|
96
|
+
// в чат второй экземпляр того же сообщения — дедупа у Bot API нет. Поэтому
|
|
97
|
+
// на таймауте останавливаемся и честно пишем 'failed': лишняя копия аварии
|
|
98
|
+
// хуже, чем пропущенная строка в логе, а сообщение, скорее всего, ушло.
|
|
99
|
+
if (err instanceof Error && err.name === 'TimeoutError') {
|
|
100
|
+
log('таймаут ответа — не повторяем: сообщение могло уже уйти');
|
|
101
|
+
return { outcome: 'fail' };
|
|
102
|
+
}
|
|
103
|
+
// Сюда попадают отказы соединения (DNS, TLS, сеть недоступна) — запрос не
|
|
104
|
+
// ушёл, дубля быть не может, фолбэк безопасен.
|
|
105
|
+
log('fetch не прошёл, пробуем curl…');
|
|
106
|
+
const curl = sendViaCurl(token, target, text);
|
|
107
|
+
return curl === 'retry' ? { outcome: 'retry', waitMs: 1000 } : { outcome: curl };
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const MAX_ATTEMPTS = 3;
|
|
111
|
+
const sendOne = async (token, target, text) => {
|
|
112
|
+
let waitMs = 0;
|
|
113
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
114
|
+
if (waitMs > 0) {
|
|
115
|
+
await sleep(waitMs);
|
|
116
|
+
}
|
|
117
|
+
const result = await attempt(token, target, text);
|
|
118
|
+
if (result.outcome === 'ok') {
|
|
119
|
+
return 'sent';
|
|
120
|
+
}
|
|
121
|
+
if (result.outcome === 'fail') {
|
|
122
|
+
return 'failed';
|
|
123
|
+
}
|
|
124
|
+
waitMs = result.waitMs;
|
|
125
|
+
}
|
|
126
|
+
log('исчерпаны попытки отправки');
|
|
127
|
+
return 'failed';
|
|
128
|
+
};
|
|
129
|
+
/** Общий хвост для `notify` и `sendReport`: токен, цели, последовательная отправка. */
|
|
130
|
+
const deliver = async (where, text) => {
|
|
131
|
+
const token = process.env.OPS_BOT_TOKEN?.trim();
|
|
132
|
+
if (!token) {
|
|
133
|
+
log('нет OPS_BOT_TOKEN — сообщение не отправлено');
|
|
134
|
+
return 'skipped';
|
|
135
|
+
}
|
|
136
|
+
if (where.length === 0) {
|
|
137
|
+
return 'skipped';
|
|
138
|
+
}
|
|
139
|
+
const results = [];
|
|
140
|
+
// Последовательно, не Promise.all: провал одной цели не должен гонять
|
|
141
|
+
// ретраи параллельно с остальными и колотить API по нескольким чатам разом.
|
|
142
|
+
for (const target of where) {
|
|
143
|
+
results.push(await sendOne(token, target, text));
|
|
144
|
+
}
|
|
145
|
+
return results.includes('sent') ? 'sent' : 'failed';
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Отправляет событие во все его цели (тема проекта + при необходимости
|
|
149
|
+
* `incidents` + чат команды). Цели идут последовательно; провал одной не
|
|
150
|
+
* отменяет остальные. `'sent'`, если хотя бы одна цель получила сообщение.
|
|
151
|
+
*/
|
|
152
|
+
export const notify = async (e) => deliver(targets(e), render(e));
|
|
153
|
+
/**
|
|
154
|
+
* Готовый HTML во вкладку «Ops» проекта — ТОЛЬКО для дневных отчётов.
|
|
155
|
+
*
|
|
156
|
+
* Зачем исключение из правила «свободного текста в API нет». Отчёт — это не
|
|
157
|
+
* событие: в нём плотная строка вроде
|
|
158
|
+
* `🎮 1284 игр (🍎 412 iOS +3 · 🤖 890 Android +5) | 📈 +240 запусков`,
|
|
159
|
+
* и разложить её в `label=value` можно только испортив. Но транспорт у отчёта
|
|
160
|
+
* ТОТ ЖЕ: ретраи, 429, таймауты, curl-фолбэк, номер вкладки. Пока его копировали
|
|
161
|
+
* в каждый скрипт, один и тот же баг с дублями на таймауте пришлось чинить
|
|
162
|
+
* дважды — в пакете и в game-publisher (27.07.2026).
|
|
163
|
+
*
|
|
164
|
+
* Граница: формат событий по-прежнему задаёт только пакет, «своё» уведомление
|
|
165
|
+
* о деплое или упавшей задаче написать нельзя. Здесь стандартизирован транспорт,
|
|
166
|
+
* а не формат — и в этом весь смысл.
|
|
167
|
+
*
|
|
168
|
+
* Всегда беззвучно: отчёт читают утром, а не по звонку.
|
|
169
|
+
*/
|
|
170
|
+
export const sendReport = async (project, html) => {
|
|
171
|
+
const forum = ROUTES[project];
|
|
172
|
+
if (!forum) {
|
|
173
|
+
log(`неизвестный проект «${project}» — отчёт не отправлен`);
|
|
174
|
+
return 'skipped';
|
|
175
|
+
}
|
|
176
|
+
return deliver([{ chat: forum.chat, thread: forum.ops, silent: true }], clampMessage(html));
|
|
177
|
+
};
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const setupTopic: (chatId: string, projectKey: string) => Promise<void>;
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `notify setup "<Название проекта>"` — заводит вкладки «⚙️ Ops» и «💬 Dev»
|
|
3
|
+
* в уже созданном форуме и печатает готовую строку для `ROUTES`.
|
|
4
|
+
*
|
|
5
|
+
* Сам форум-супергруппу бот создать не может — Telegram разрешает это только
|
|
6
|
+
* живому аккаунту. Поэтому порядок для нового проекта такой:
|
|
7
|
+
* 1. создать группу в Telegram, включить в ней «Темы», добавить
|
|
8
|
+
* @mikita_ops_bot администратором с правом «Управление темами»;
|
|
9
|
+
* 2. `notify setup <chat_id>` — заведёт обе вкладки и напечатает строку;
|
|
10
|
+
* 3. вставить строку в `src/routes.ts`.
|
|
11
|
+
*
|
|
12
|
+
* Шаг 1 делается один раз на проект и занимает полминуты; шаги 2–3 —
|
|
13
|
+
* механические.
|
|
14
|
+
*/
|
|
15
|
+
const log = (msg) => console.error(`[notify] ${msg}`);
|
|
16
|
+
const createTopic = async (token, chat, name, color) => {
|
|
17
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/createForumTopic`, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'Content-Type': 'application/json' },
|
|
20
|
+
body: JSON.stringify({ chat_id: chat, name, icon_color: color }),
|
|
21
|
+
signal: AbortSignal.timeout(10_000)
|
|
22
|
+
});
|
|
23
|
+
const body = (await res.json());
|
|
24
|
+
if (!body.ok || !body.result) {
|
|
25
|
+
log(`не удалось создать «${name}»: ${body.description ?? `HTTP ${res.status}`}`);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return body.result.message_thread_id;
|
|
29
|
+
};
|
|
30
|
+
export const setupTopic = async (chatId, projectKey) => {
|
|
31
|
+
const token = process.env.OPS_BOT_TOKEN?.trim();
|
|
32
|
+
if (!token) {
|
|
33
|
+
log('нет OPS_BOT_TOKEN — не могу создать вкладки');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const ops = await createTopic(token, chatId, '⚙️ Ops', 9367192);
|
|
37
|
+
const dev = await createTopic(token, chatId, '💬 Dev', 7322096);
|
|
38
|
+
if (ops === null || dev === null) {
|
|
39
|
+
log('проверь: бот админ группы с правом «Управление темами», а темы в группе включены?');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
log(`вкладки созданы: Ops=${ops}, Dev=${dev}`);
|
|
43
|
+
log('добавь в src/routes.ts:');
|
|
44
|
+
log(` ${JSON.stringify(projectKey)}: { chat: '${chatId}', ops: ${ops}, dev: ${dev} },`);
|
|
45
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mikitasazan/notify",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mikitasazan/notify.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/mikitasazan/notify/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/mikitasazan/notify#readme",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.18"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./dist/index.js",
|
|
26
|
+
"./cli": "./dist/cli.js"
|
|
27
|
+
},
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"bin": {
|
|
30
|
+
"notify": "./dist/cli.js"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc -p tsconfig.build.json",
|
|
34
|
+
"prepare": "tsc -p tsconfig.build.json",
|
|
35
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
36
|
+
"test": "node --test src/*.test.ts"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^24.13.3",
|
|
40
|
+
"typescript": "^5.9.3"
|
|
41
|
+
}
|
|
42
|
+
}
|