@mikitasazan/notify 1.0.0 → 1.0.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
@@ -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
- **Ноль рантайм-зависимостей.** Node 22.18+ / 24+ исполняет `.ts` напрямую
26
- (проверено на macOS и на проде playhub, Node 22.22.2) — сборки нет,
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
- event = { type: command, ...payload };
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: Number(one('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: Number(one('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
@@ -70,6 +70,7 @@ const renderDeploy = (e) => {
70
70
  commitLine,
71
71
  kv('откуда', e.via),
72
72
  kv('куда', e.target),
73
+ kv('примечание', e.note),
73
74
  link(e.url, 'Открыть логи')
74
75
  ]);
75
76
  };
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
- 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}`}`);
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
- if (ops === null || dev === null) {
39
- log('проверь: бот админ группы с правом «Управление темами», а темы в группе включены?');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",