@mikitasazan/notify 1.0.0 → 1.0.2
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 +6 -4
- package/dist/cli.js +17 -5
- package/dist/events.d.ts +3 -1
- package/dist/events.js +1 -1
- package/dist/render.js +1 -0
- package/dist/send.js +15 -2
- package/dist/setup.js +27 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @mikitasazan/notify
|
|
2
2
|
|
|
3
3
|
Единая типизированная отправка Telegram-уведомлений: **форум на проект**,
|
|
4
|
-
внутри вкладки «⚙️ Ops» (роботы) и «💬 Dev» (люди), один бот,
|
|
4
|
+
внутри вкладки «⚙️ Ops» (роботы) и «💬 Dev» (люди), один бот, восемь типов
|
|
5
5
|
событий. Заменяет 18 разных имён переменных и 4+ независимых реализации
|
|
6
6
|
`jq | curl`, что были в playhub, arvent, game-publisher до 26.07.2026.
|
|
7
7
|
|
|
@@ -22,9 +22,11 @@
|
|
|
22
22
|
проект значит дописать одну строку в `src/routes.ts`, больше нигде ничего
|
|
23
23
|
заводить не нужно.
|
|
24
24
|
|
|
25
|
-
**Ноль рантайм-зависимостей.**
|
|
26
|
-
(проверено на macOS и на проде
|
|
27
|
-
в GitHub Actions ставить нечего.
|
|
25
|
+
**Ноль рантайм-зависимостей.** Composite-action и консьюмеры не собирают ничего:
|
|
26
|
+
Node 22.18+ / 24+ исполняет `.ts` напрямую (проверено на macOS и на проде
|
|
27
|
+
playhub, Node 22.22.2), в GitHub Actions ставить нечего. Релиз пакета в npm
|
|
28
|
+
**собирается** в `dist` (`prepare` → `tsc`) — поэтому npm-консьюмеры получают
|
|
29
|
+
скомпилированный JS и типы.
|
|
28
30
|
|
|
29
31
|
## Установка
|
|
30
32
|
|
package/dist/cli.js
CHANGED
|
@@ -70,6 +70,16 @@ for (let i = 1; i < args.length; i++) {
|
|
|
70
70
|
flags.set(key, [...(flags.get(key) ?? []), next]);
|
|
71
71
|
}
|
|
72
72
|
const one = (key) => flags.get(key)?.[0];
|
|
73
|
+
// Число с явной ошибкой разбора, иначе рендер рисовал «PR #NaN».
|
|
74
|
+
const num = (key) => {
|
|
75
|
+
const raw = one(key);
|
|
76
|
+
const n = Number(raw);
|
|
77
|
+
if (raw === undefined || Number.isNaN(n)) {
|
|
78
|
+
parseErrors.push(`--${key}: ожидается число, получено «${raw ?? ''}»`);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
return n;
|
|
82
|
+
};
|
|
73
83
|
// --item "текст" или --item "текст|https://ссылка"
|
|
74
84
|
const items = () => (flags.get('item') ?? []).map((raw) => {
|
|
75
85
|
const idx = raw.lastIndexOf('|');
|
|
@@ -85,7 +95,6 @@ const PR_ALIASES = {
|
|
|
85
95
|
reopened: 'opened',
|
|
86
96
|
ready_for_review: 'ready_for_review',
|
|
87
97
|
review_requested: 'review_requested',
|
|
88
|
-
review_request_removed: 'review_requested',
|
|
89
98
|
approved: 'approved',
|
|
90
99
|
changes_requested: 'changes_requested',
|
|
91
100
|
merged: 'merged',
|
|
@@ -132,7 +141,9 @@ let event;
|
|
|
132
141
|
if (flags.has('json')) {
|
|
133
142
|
try {
|
|
134
143
|
const payload = JSON.parse(readFileSync(0, 'utf-8'));
|
|
135
|
-
|
|
144
|
+
// type — за командой, не за payload: иначе --pr <объект с type:deploy>
|
|
145
|
+
// отправил бы событие другого типа.
|
|
146
|
+
event = { ...payload, type: command };
|
|
136
147
|
}
|
|
137
148
|
catch (err) {
|
|
138
149
|
log(`не удалось разобрать --json со stdin: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -149,7 +160,8 @@ else {
|
|
|
149
160
|
commitUrl: one('commit-url'),
|
|
150
161
|
url: one('url'),
|
|
151
162
|
target: one('target'),
|
|
152
|
-
via: one('via')
|
|
163
|
+
via: one('via'),
|
|
164
|
+
note: one('note')
|
|
153
165
|
};
|
|
154
166
|
break;
|
|
155
167
|
case 'job':
|
|
@@ -191,7 +203,7 @@ else {
|
|
|
191
203
|
type: 'pr',
|
|
192
204
|
project: project(),
|
|
193
205
|
action: prAction(one('action')),
|
|
194
|
-
number:
|
|
206
|
+
number: num('number'),
|
|
195
207
|
title: one('title') ?? '(без заголовка)',
|
|
196
208
|
author: one('author'),
|
|
197
209
|
reviewer: one('reviewer'),
|
|
@@ -203,7 +215,7 @@ else {
|
|
|
203
215
|
type: 'issue',
|
|
204
216
|
project: project(),
|
|
205
217
|
action: issueAction(one('action')),
|
|
206
|
-
number:
|
|
218
|
+
number: num('number'),
|
|
207
219
|
title: one('title') ?? '(без заголовка)',
|
|
208
220
|
author: one('author'),
|
|
209
221
|
assignee: one('assignee'),
|
package/dist/events.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ export type NotifyEvent =
|
|
|
41
41
|
* какой сработал.
|
|
42
42
|
*/
|
|
43
43
|
via?: string;
|
|
44
|
+
/** Пояснение: почему отменён/пропущен прогон после деплоя. */
|
|
45
|
+
note?: string;
|
|
44
46
|
}
|
|
45
47
|
/** Регулярная задача по расписанию: импорт игр, бэкап БД, валидатор. */
|
|
46
48
|
| {
|
|
@@ -122,5 +124,5 @@ export type NotifyEvent =
|
|
|
122
124
|
expected?: string;
|
|
123
125
|
};
|
|
124
126
|
export type EventType = NotifyEvent['type'];
|
|
125
|
-
/** Красное = со
|
|
127
|
+
/** Красное = со звуком. Всё остальное — тихо. (Отдельной темы «инциденты» больше нет — авария видна в ленте проекта.) */
|
|
126
128
|
export declare const severity: (e: NotifyEvent) => "info" | "error";
|
package/dist/events.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - обязательные поля не добавляются никогда — только новый тип события.
|
|
10
10
|
* Тогда старый вызывающий код и новый пакет совместимы в обе стороны.
|
|
11
11
|
*/
|
|
12
|
-
/** Красное = со
|
|
12
|
+
/** Красное = со звуком. Всё остальное — тихо. (Отдельной темы «инциденты» больше нет — авария видна в ленте проекта.) */
|
|
13
13
|
export const severity = (e) => {
|
|
14
14
|
if (e.type === 'incident' || e.type === 'heartbeat_miss') {
|
|
15
15
|
return 'error';
|
package/dist/render.js
CHANGED
package/dist/send.js
CHANGED
|
@@ -100,8 +100,13 @@ const attempt = async (token, target, text) => {
|
|
|
100
100
|
log('таймаут ответа — не повторяем: сообщение могло уже уйти');
|
|
101
101
|
return { outcome: 'fail' };
|
|
102
102
|
}
|
|
103
|
-
// Сюда попадают отказы соединения (DNS, TLS, сеть недоступна) — запрос
|
|
104
|
-
// ушёл,
|
|
103
|
+
// Сюда попадают отказы соединения (DNS, TLS, сеть недоступна) — запрос
|
|
104
|
+
// почти наверняка не ушёл, и curl-фолбэк безопасен. НЕ абсолютно: обрыв
|
|
105
|
+
// (reset/truncation) ПОСЛЕ того, как Telegram принял POST, тоже кидает
|
|
106
|
+
// исключение — тогда повтор даст дубль. Это редкий случай; полное
|
|
107
|
+
// отсутствие дублей невозможно без idempotency-key у Bot API (его нет).
|
|
108
|
+
// Логика прежняя: дубль на редком reset — меньшее зло, чем потеря
|
|
109
|
+
// сообщения на частом сетевом сбое.
|
|
105
110
|
log('fetch не прошёл, пробуем curl…');
|
|
106
111
|
const curl = sendViaCurl(token, target, text);
|
|
107
112
|
return curl === 'retry' ? { outcome: 'retry', waitMs: 1000 } : { outcome: curl };
|
|
@@ -133,6 +138,14 @@ const deliver = async (where, text) => {
|
|
|
133
138
|
log('нет OPS_BOT_TOKEN — сообщение не отправлено');
|
|
134
139
|
return 'skipped';
|
|
135
140
|
}
|
|
141
|
+
// Токен interpolируется в URL и в curl-конфиг (`url = "...bot${token}..."`).
|
|
142
|
+
// Валидный токен Telegram — это `\d+:[\w-]+`; что-либо с кавычкой/переводом
|
|
143
|
+
// строки/`?` сломало бы разбор (инъекция директивы curl или query-хвост).
|
|
144
|
+
// Это требует покорёженного секрета, но проверка копеечная.
|
|
145
|
+
if (!/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
|
|
146
|
+
log('OPS_BOT_TOKEN не похож на токен Telegram — отправка отменена');
|
|
147
|
+
return 'skipped';
|
|
148
|
+
}
|
|
136
149
|
if (where.length === 0) {
|
|
137
150
|
return 'skipped';
|
|
138
151
|
}
|
package/dist/setup.js
CHANGED
|
@@ -14,18 +14,25 @@
|
|
|
14
14
|
*/
|
|
15
15
|
const log = (msg) => console.error(`[notify] ${msg}`);
|
|
16
16
|
const createTopic = async (token, chat, name, color) => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/createForumTopic`, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({ chat_id: chat, name, icon_color: color }),
|
|
22
|
+
signal: AbortSignal.timeout(10_000)
|
|
23
|
+
});
|
|
24
|
+
const body = (await res.json());
|
|
25
|
+
if (!body.ok || !body.result) {
|
|
26
|
+
log(`не удалось создать «${name}»: ${body.description ?? `HTTP ${res.status}`}`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return body.result.message_thread_id;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
// Сеть/таймаут/нечитаемый ответ — не роняем CLI (его контракт: всегда exit 0).
|
|
33
|
+
log(`не удалось создать «${name}»: ${err instanceof Error ? err.message : String(err)}`);
|
|
26
34
|
return null;
|
|
27
35
|
}
|
|
28
|
-
return body.result.message_thread_id;
|
|
29
36
|
};
|
|
30
37
|
export const setupTopic = async (chatId, projectKey) => {
|
|
31
38
|
const token = process.env.OPS_BOT_TOKEN?.trim();
|
|
@@ -35,8 +42,16 @@ export const setupTopic = async (chatId, projectKey) => {
|
|
|
35
42
|
}
|
|
36
43
|
const ops = await createTopic(token, chatId, '⚙️ Ops', 9367192);
|
|
37
44
|
const dev = await createTopic(token, chatId, '💬 Dev', 7322096);
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
// Частичный успех: если создался только Ops — печатаем его, иначе повторный
|
|
46
|
+
// запуск создал бы ДРУГУЮ тему Ops, а старый id потерялся бы.
|
|
47
|
+
if (ops === null) {
|
|
48
|
+
log('Ops не создан — проверь: бот админ группы с правом «Управление темами», темы включены?');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (dev === null) {
|
|
52
|
+
log(`Ops создан (id=${ops}), Dev нет — добавь Dev вручную или повтори, и возьми ops=${ops}`);
|
|
53
|
+
log('добавь в src/routes.ts (dev подставь после):');
|
|
54
|
+
log(` ${JSON.stringify(projectKey)}: { chat: '${chatId}', ops: ${ops}, dev: <вставь> },`);
|
|
40
55
|
return;
|
|
41
56
|
}
|
|
42
57
|
log(`вкладки созданы: Ops=${ops}, Dev=${dev}`);
|
package/package.json
CHANGED