@pachca/cli 2026.11.1 → 2026.11.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/CHANGELOG.md +5 -0
- package/README.md +20 -0
- package/README.template.md +20 -0
- package/dist/base-command.js +15 -6
- package/dist/client.js +12 -6
- package/dist/commands/bots/listen-events.js +248 -0
- package/dist/commands/doctor.js +6 -6
- package/dist/data/changelog.json +15 -0
- package/dist/webhook-listener.js +262 -0
- package/oclif.manifest.json +1 -1
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026.11.2 (10 ноября 2026)
|
|
4
|
+
|
|
5
|
+
- **Добавлено** (bots listen-events): События бота в реальном времени без публичного адреса: JSONL в терминале или пересылка на локальный обработчик, как исходящий вебхук, с подписью, свежим `webhook_timestamp` и ответом на отправку формы. Запись удаляется из истории после ответа 2xx.
|
|
6
|
+
- **Изменено**: Для запуска CLI требуется Node.js 20.3.0 или новее
|
|
7
|
+
|
|
3
8
|
## 2026.11.1 (9 ноября 2026)
|
|
4
9
|
|
|
5
10
|
- **Изменено** (auth status): Показывает токен, с которым работают команды, в том числе из `--token` и `PACHCA_TOKEN`. Владельца, права и срок жизни такого токена команда спрашивает у сервера и называет сохранённый профиль, который не используется, пока задан `PACHCA_TOKEN`.
|
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
## Установка
|
|
6
6
|
|
|
7
|
+
Требуется Node.js 20.3.0 или новее.
|
|
8
|
+
|
|
7
9
|
```
|
|
8
10
|
npm install -g @pachca/cli
|
|
9
11
|
|
|
@@ -292,6 +294,24 @@ cat data.csv | pachca upload - --name data.csv
|
|
|
292
294
|
|
|
293
295
|
Команда `upload` автоматически получает подпись (`POST /uploads`) и загружает файл на S3. Возвращает `key` — используйте его в `--files` при создании сообщений. Расширение `name` в файле сообщения должно совпадать с расширением имени в `key`.
|
|
294
296
|
|
|
297
|
+
## События бота
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
PACHCA_TOKEN=$PACHCA_BOT_TOKEN pachca bots listen-events
|
|
301
|
+
PACHCA_TOKEN=$PACHCA_BOT_TOKEN PACHCA_SIGNING_SECRET=$PACHCA_SIGNING_SECRET \
|
|
302
|
+
pachca bots listen-events --forward-to http://localhost:3000/webhook
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Нужен токен бота с правом `webhooks:events:read` и включённая настройка «Сохранять историю событий». Команда подключается к потоку событий, дожидается подтверждения подписки, дочитывает историю от старых событий к новым и меняет ссылку до истечения срока.
|
|
306
|
+
|
|
307
|
+
Без `--forward-to` каждая запись истории (`id`, `event_type`, `payload`, `created_at`) выводится строкой JSONL и остаётся в истории. При запуске печатается вся накопленная история, после переподключения только новые записи.
|
|
308
|
+
|
|
309
|
+
С `--forward-to` команда отправляет `payload` запросом POST, как исходящий вебхук, и удаляет запись из истории после ответа 2xx. Для этого нужно право `webhooks:events:delete`. Если задан `PACHCA_SIGNING_SECRET`, тело подписано заголовком `Pachca-Signature`. В `webhook_timestamp` стоит время пересылки, а в `Idempotency-Key` приходит `id` записи. Ответ обработчика на отправку формы (2xx, либо 400 или 422 с `errors`) команда передаёт в Пачку, для этого нужно право `views:write`. Пачка принимает ответ на форму в течение 5 секунд с отправки. Отправку формы старше 10 секунд команда обработчику не пересылает и удаляет из истории: сотрудник к этому времени уже видел ошибку и мог отправить форму снова. Другой ответ, ошибка или таймаут останавливают команду, а запись остаётся в истории. Редиректы не выполняются. Запись, которую обработчик не может принять, удалите тем же токеном бота командой `PACHCA_TOKEN=$PACHCA_BOT_TOKEN pachca bots remove-event <id> --force`: `id` есть в сообщении об ошибке.
|
|
310
|
+
|
|
311
|
+
`--forward-to` рассчитан на бота без адреса вебхука. Запускайте для бота одну такую команду: история у слушателей общая.
|
|
312
|
+
|
|
313
|
+
`--timeout` задаёт таймаут запросов к API и к обработчику и ожидания подтверждения подписки в секундах (по умолчанию `defaults.timeout`, `PACHCA_TIMEOUT` или 30). `--no-retry` отключает переподключение, а отдельные запросы к API по-прежнему повторяются при 429 и 5xx. `--verbose` не печатает HTTP-диагностику этой команды, чтобы подписанная ссылка не попала в логи. Ctrl+C завершает команду.
|
|
314
|
+
|
|
295
315
|
## Пагинация
|
|
296
316
|
|
|
297
317
|
```
|
package/README.template.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
## Установка
|
|
6
6
|
|
|
7
|
+
Требуется Node.js 20.3.0 или новее.
|
|
8
|
+
|
|
7
9
|
```
|
|
8
10
|
npm install -g @pachca/cli
|
|
9
11
|
|
|
@@ -74,6 +76,24 @@ cat data.csv | pachca upload - --name data.csv
|
|
|
74
76
|
|
|
75
77
|
Команда `upload` автоматически получает подпись (`POST /uploads`) и загружает файл на S3. Возвращает `key` — используйте его в `--files` при создании сообщений. Расширение `name` в файле сообщения должно совпадать с расширением имени в `key`.
|
|
76
78
|
|
|
79
|
+
## События бота
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
PACHCA_TOKEN=$PACHCA_BOT_TOKEN pachca bots listen-events
|
|
83
|
+
PACHCA_TOKEN=$PACHCA_BOT_TOKEN PACHCA_SIGNING_SECRET=$PACHCA_SIGNING_SECRET \
|
|
84
|
+
pachca bots listen-events --forward-to http://localhost:3000/webhook
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Нужен токен бота с правом `webhooks:events:read` и включённая настройка «Сохранять историю событий». Команда подключается к потоку событий, дожидается подтверждения подписки, дочитывает историю от старых событий к новым и меняет ссылку до истечения срока.
|
|
88
|
+
|
|
89
|
+
Без `--forward-to` каждая запись истории (`id`, `event_type`, `payload`, `created_at`) выводится строкой JSONL и остаётся в истории. При запуске печатается вся накопленная история, после переподключения только новые записи.
|
|
90
|
+
|
|
91
|
+
С `--forward-to` команда отправляет `payload` запросом POST, как исходящий вебхук, и удаляет запись из истории после ответа 2xx. Для этого нужно право `webhooks:events:delete`. Если задан `PACHCA_SIGNING_SECRET`, тело подписано заголовком `Pachca-Signature`. В `webhook_timestamp` стоит время пересылки, а в `Idempotency-Key` приходит `id` записи. Ответ обработчика на отправку формы (2xx, либо 400 или 422 с `errors`) команда передаёт в Пачку, для этого нужно право `views:write`. Пачка принимает ответ на форму в течение 5 секунд с отправки. Отправку формы старше 10 секунд команда обработчику не пересылает и удаляет из истории: сотрудник к этому времени уже видел ошибку и мог отправить форму снова. Другой ответ, ошибка или таймаут останавливают команду, а запись остаётся в истории. Редиректы не выполняются. Запись, которую обработчик не может принять, удалите тем же токеном бота командой `PACHCA_TOKEN=$PACHCA_BOT_TOKEN pachca bots remove-event <id> --force`: `id` есть в сообщении об ошибке.
|
|
92
|
+
|
|
93
|
+
`--forward-to` рассчитан на бота без адреса вебхука. Запускайте для бота одну такую команду: история у слушателей общая.
|
|
94
|
+
|
|
95
|
+
`--timeout` задаёт таймаут запросов к API и к обработчику и ожидания подтверждения подписки в секундах (по умолчанию `defaults.timeout`, `PACHCA_TIMEOUT` или 30). `--no-retry` отключает переподключение, а отдельные запросы к API по-прежнему повторяются при 429 и 5xx. `--verbose` не печатает HTTP-диагностику этой команды, чтобы подписанная ссылка не попала в логи. Ctrl+C завершает команду.
|
|
96
|
+
|
|
77
97
|
## Пагинация
|
|
78
98
|
|
|
79
99
|
```
|
package/dist/base-command.js
CHANGED
|
@@ -246,29 +246,38 @@ export class BaseCommand extends Command {
|
|
|
246
246
|
reportScopeError(err) {
|
|
247
247
|
const format = this.getOutputFormat();
|
|
248
248
|
const scope = typeof err.details.scope === 'string' ? err.details.scope : undefined;
|
|
249
|
-
// `auth status` checks the token every command uses. A bare run
|
|
250
|
-
//
|
|
251
|
-
// to be named again. The token value is
|
|
249
|
+
// `auth status` checks the token every command uses. A bare run reads the active
|
|
250
|
+
// profile; a token or profile named on this command, by a flag or by a variable that
|
|
251
|
+
// may have been set for this one command, has to be named again. The token value is
|
|
252
|
+
// never echoed back.
|
|
252
253
|
const source = tokenSource({ token: this.parsedFlags.token, profile: this.parsedFlags.profile });
|
|
253
254
|
const human = format !== 'json' && process.stderr.isTTY;
|
|
254
255
|
const again = source.kind === 'flag'
|
|
255
256
|
? human ? ' --token <токен>' : ' --token <token>'
|
|
256
|
-
: source.kind === 'profile' && source.via
|
|
257
|
+
: source.kind === 'profile' && source.via !== 'active_profile'
|
|
257
258
|
? ` --profile ${source.profileName}`
|
|
258
259
|
: '';
|
|
260
|
+
// A token from the environment may have been set for this one command
|
|
261
|
+
// (`PACHCA_TOKEN=… pachca …`): a bare `auth status` would then read another one.
|
|
262
|
+
const check = source.kind === 'env'
|
|
263
|
+
? `PACHCA_TOKEN=${human ? '<токен>' : '<token>'} pachca auth status`
|
|
264
|
+
: 'pachca auth status';
|
|
259
265
|
if (!human) {
|
|
260
266
|
// A saved profile keeps the scope list from login, which may still name the
|
|
261
267
|
// scope just refused: only the server's answer settles it.
|
|
262
268
|
const remote = source.kind === 'profile' ? ' --remote' : '';
|
|
263
|
-
outputError({ ...err.details, hint:
|
|
269
|
+
outputError({ ...err.details, hint: `${check}${remote}${again}` }, format);
|
|
264
270
|
this.exit(3);
|
|
265
271
|
}
|
|
266
272
|
const resolved = this.tryResolveProfile();
|
|
267
273
|
const profile = resolved?.profile;
|
|
268
274
|
process.stderr.write(`✗ Не хватает права${scope ? ` ${scope}` : ''}.\n`);
|
|
275
|
+
// A command's own check before any request (no HTTP status) says why it needs the right.
|
|
276
|
+
if (err.details.code === null)
|
|
277
|
+
process.stderr.write(` ${err.details.error}.\n`);
|
|
269
278
|
if (!profile) {
|
|
270
279
|
process.stderr.write(` У токена ${scope ? 'этого' : 'нужного'} права нет.\n`);
|
|
271
|
-
process.stderr.write(` Проверить токен и его права:
|
|
280
|
+
process.stderr.write(` Проверить токен и его права: ${check}${again}\n`);
|
|
272
281
|
this.exit(3);
|
|
273
282
|
}
|
|
274
283
|
// The scope list in the profile is a copy taken at login. When it still names
|
package/dist/client.js
CHANGED
|
@@ -98,14 +98,20 @@ function parseRetryAfter(header) {
|
|
|
98
98
|
return null;
|
|
99
99
|
return Math.max(ms, 0);
|
|
100
100
|
}
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Seconds to wait: the value asked for (a flag or `defaults.timeout`), else PACHCA_TIMEOUT, else 30.
|
|
103
|
+
* A value that is not a positive number means 30: a timeout of 0 would abort on the next tick,
|
|
104
|
+
* which is never what anyone means by `--timeout 0`. Timers take at most 2^31 - 1 ms.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveTimeoutSeconds(requested) {
|
|
103
107
|
const rawEnvTimeout = process.env.PACHCA_TIMEOUT ? Number(process.env.PACHCA_TIMEOUT) : undefined;
|
|
104
108
|
const envTimeout = rawEnvTimeout && !Number.isNaN(rawEnvTimeout) ? rawEnvTimeout : undefined;
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
const value = Number(requested ?? envTimeout ?? DEFAULT_TIMEOUT);
|
|
110
|
+
return Number.isFinite(value) && value > 0 ? Math.min(value, 2_147_483) : DEFAULT_TIMEOUT;
|
|
111
|
+
}
|
|
112
|
+
export async function request(opts, clientFlags) {
|
|
113
|
+
const url = buildUrl(opts.path, opts.query);
|
|
114
|
+
const timeoutSeconds = resolveTimeoutSeconds(opts.timeout ?? clientFlags?.timeout);
|
|
109
115
|
const noRetry = opts.noRetry ?? clientFlags?.['no-retry'] ?? false;
|
|
110
116
|
const method = opts.method.toUpperCase();
|
|
111
117
|
// The same set is retried after a 5xx and after a network error. A repeated
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { Flags } from '@oclif/core';
|
|
3
|
+
import { BaseCommand } from '../../base-command.js';
|
|
4
|
+
import { ApiError, resolveTimeoutSeconds } from '../../client.js';
|
|
5
|
+
import { listenWebhookEvents } from '../../webhook-listener.js';
|
|
6
|
+
/** A `view_submit` payload carries what the answer to the form needs. */
|
|
7
|
+
function formSubmit(payload) {
|
|
8
|
+
const p = payload;
|
|
9
|
+
if (!p || p.type !== 'view' || p.event !== 'submit')
|
|
10
|
+
return undefined;
|
|
11
|
+
if (typeof p.view_id !== 'string' || typeof p.submit_id !== 'string')
|
|
12
|
+
return undefined;
|
|
13
|
+
return { view_id: p.view_id, submit_id: p.submit_id };
|
|
14
|
+
}
|
|
15
|
+
/** Pachca stamps every delivery attempt anew, so a record replayed from the history gets the time of forwarding. */
|
|
16
|
+
function withFreshTimestamp(payload) {
|
|
17
|
+
const p = payload;
|
|
18
|
+
if (!p || typeof p !== 'object' || Array.isArray(p) || typeof p.webhook_timestamp !== 'number')
|
|
19
|
+
return payload;
|
|
20
|
+
return { ...p, webhook_timestamp: Math.floor(Date.now() / 1000) };
|
|
21
|
+
}
|
|
22
|
+
// Pachca takes the answer to a form for 5 seconds and then shows the employee a sending error.
|
|
23
|
+
// A submission older than this is one the employee saw fail. The age is measured on Pachca's clock,
|
|
24
|
+
// from the Date header of the last API response: 10 s leave room for its whole seconds and transit.
|
|
25
|
+
const FORM_STALE_MS = 10_000;
|
|
26
|
+
// With no Date header the local clock may differ from Pachca's, so the margin is wider.
|
|
27
|
+
const FORM_STALE_LOCAL_MS = 60_000;
|
|
28
|
+
export default class BotsListenEvents extends BaseCommand {
|
|
29
|
+
static summary = 'Слушать события бота в реальном времени';
|
|
30
|
+
static description = `Слушать события бота: JSONL в stdout или пересылка на локальный HTTP-обработчик.
|
|
31
|
+
|
|
32
|
+
Нужен токен бота с webhooks:events:read и включённая история событий.
|
|
33
|
+
Без --forward-to записи остаются в истории. С --forward-to запись удаляется из истории после ответа 2xx.
|
|
34
|
+
Для этого нужно право webhooks:events:delete. Если задан PACHCA_SIGNING_SECRET, запрос подписан им, как вебхук.
|
|
35
|
+
Ответ обработчика на отправку формы CLI передаёт в Пачку, для этого нужно право views:write.
|
|
36
|
+
Ошибка обработчика останавливает команду. После перезапуска событие может прийти снова.`;
|
|
37
|
+
static scope = 'webhooks:events:read';
|
|
38
|
+
static examples = [
|
|
39
|
+
'PACHCA_TOKEN=$PACHCA_BOT_TOKEN <%= config.bin %> bots listen-events',
|
|
40
|
+
'PACHCA_TOKEN=$PACHCA_BOT_TOKEN PACHCA_SIGNING_SECRET=$PACHCA_SIGNING_SECRET <%= config.bin %> bots listen-events --forward-to http://localhost:3000/webhook',
|
|
41
|
+
];
|
|
42
|
+
static flags = {
|
|
43
|
+
...BaseCommand.baseFlags,
|
|
44
|
+
'forward-to': Flags.string({ description: 'HTTP(S)-адрес обработчика: событие уходит POST, запись удаляется из истории после 2xx' }),
|
|
45
|
+
'no-retry': Flags.boolean({
|
|
46
|
+
description: 'Не переподключаться к потоку после обрыва. Запросы к API по-прежнему повторяются при 429 и 5xx',
|
|
47
|
+
default: false,
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
/** Without views:write the answer to a form cannot reach Pachca, and trying would fail the record. */
|
|
51
|
+
answerForms = true;
|
|
52
|
+
/** Pachca's clock minus the local one, from the Date header of the last API response, with the local stamps it was taken at. */
|
|
53
|
+
serverClock;
|
|
54
|
+
// The stream response contains a capability URL: suppress HTTP body diagnostics for this command.
|
|
55
|
+
// --no-retry here means «do not reconnect»: single API calls keep their retries on 429 and 5xx.
|
|
56
|
+
getClientFlags() {
|
|
57
|
+
return { ...super.getClientFlags(), verbose: false, quiet: true, 'no-retry': false };
|
|
58
|
+
}
|
|
59
|
+
async run() {
|
|
60
|
+
const { flags } = await this.parse(BotsListenEvents);
|
|
61
|
+
this.parsedFlags = flags;
|
|
62
|
+
let target;
|
|
63
|
+
if (flags['forward-to']) {
|
|
64
|
+
try {
|
|
65
|
+
target = new URL(flags['forward-to']);
|
|
66
|
+
}
|
|
67
|
+
catch { /* validation below */ }
|
|
68
|
+
if (!target || !['http:', 'https:'].includes(target.protocol) || target.username || target.password) {
|
|
69
|
+
this.validationError([{ message: 'Нужен HTTP(S)-адрес без логина и пароля', flag: 'forward-to' }]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if ((flags.output && flags.output !== 'json') || flags.plain || flags.columns || flags.quiet) {
|
|
73
|
+
this.validationError([{ message: 'События выводятся в JSONL; форматирование и --quiet не поддерживаются' }]);
|
|
74
|
+
}
|
|
75
|
+
if (flags['dry-run']) {
|
|
76
|
+
await this.apiRequest({ method: 'GET', path: '/webhooks/events/stream' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (target)
|
|
80
|
+
await this.checkForwardingScopes();
|
|
81
|
+
// The same rule as API calls: --timeout, defaults.timeout, PACHCA_TIMEOUT, 30 seconds.
|
|
82
|
+
const timeoutMs = Math.ceil(resolveTimeoutSeconds(this.getClientFlags().timeout) * 1_000);
|
|
83
|
+
const secret = process.env.PACHCA_SIGNING_SECRET || undefined;
|
|
84
|
+
if (target && !secret) {
|
|
85
|
+
process.stderr.write('PACHCA_SIGNING_SECRET не задан: события уходят без заголовка Pachca-Signature\n');
|
|
86
|
+
}
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
let interrupted = false;
|
|
89
|
+
const stop = () => { interrupted = true; controller.abort(); };
|
|
90
|
+
process.once('SIGINT', stop);
|
|
91
|
+
process.once('SIGTERM', stop);
|
|
92
|
+
try {
|
|
93
|
+
await listenWebhookEvents({
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
retry: !flags['no-retry'],
|
|
96
|
+
timeoutMs,
|
|
97
|
+
acknowledge: !!target,
|
|
98
|
+
request: async (opts) => {
|
|
99
|
+
const response = await this.apiRequest(opts);
|
|
100
|
+
const date = Date.parse(response.headers?.get('date') ?? '');
|
|
101
|
+
if (Number.isFinite(date)) {
|
|
102
|
+
const wall = Date.now();
|
|
103
|
+
this.serverClock = { offset: date - wall, wall, mono: performance.now() };
|
|
104
|
+
}
|
|
105
|
+
return response.data;
|
|
106
|
+
},
|
|
107
|
+
onReconnect: () => { process.stderr.write('Переподключение к потоку событий…\n'); },
|
|
108
|
+
onEvent: async (event) => {
|
|
109
|
+
if (!target) {
|
|
110
|
+
await new Promise((resolve, reject) => {
|
|
111
|
+
process.stdout.write(JSON.stringify(event) + '\n', (error) => error ? reject(error) : resolve());
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
await this.forward(event, target, secret, controller.signal, timeoutMs);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
controller.abort();
|
|
121
|
+
process.removeListener('SIGINT', stop);
|
|
122
|
+
process.removeListener('SIGTERM', stop);
|
|
123
|
+
}
|
|
124
|
+
if (interrupted) {
|
|
125
|
+
// An API call in flight would keep the process alive until its timeout and retries end.
|
|
126
|
+
// A JSONL line may still wait in the pipe: let it out, but do not wait on a stuck reader.
|
|
127
|
+
await Promise.race([
|
|
128
|
+
new Promise((resolve) => { process.stdout.write('', () => resolve()); }),
|
|
129
|
+
new Promise((resolve) => { setTimeout(resolve, 1_000); }),
|
|
130
|
+
]);
|
|
131
|
+
process.exit(0);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Without webhooks:events:delete every forwarded event would come again on the next run. */
|
|
135
|
+
async checkForwardingScopes() {
|
|
136
|
+
const body = (await this.apiRequest({ method: 'GET', path: '/oauth/token/info' })).data;
|
|
137
|
+
const scopes = body?.data?.scopes ?? [];
|
|
138
|
+
const has = (scope) => scopes.includes(scope) || scopes.includes('all');
|
|
139
|
+
if (!has('webhooks:events:delete')) {
|
|
140
|
+
throw new ApiError({
|
|
141
|
+
error: 'Для --forward-to токену нужно право webhooks:events:delete: без него события придут снова при каждом запуске',
|
|
142
|
+
code: null,
|
|
143
|
+
type: 'PACHCA_SCOPE_ERROR',
|
|
144
|
+
scope: 'webhooks:events:delete',
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
this.answerForms = has('views:write');
|
|
148
|
+
if (!this.answerForms) {
|
|
149
|
+
process.stderr.write('У токена нет права views:write: ответ обработчика на отправку формы не дойдёт до Пачки, и сотрудник увидит ошибку отправки\n');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async forward(event, target, secret, signal, timeoutMs) {
|
|
153
|
+
const name = `${event.id} (${event.event_type})`;
|
|
154
|
+
const form = formSubmit(event.payload);
|
|
155
|
+
const clock = this.serverClock;
|
|
156
|
+
// A local clock that jumped (time sync, a virtual machine catching up, a manual change) or a machine
|
|
157
|
+
// that slept since the stamp breaks the offset: the wider margin on the local clock applies then.
|
|
158
|
+
const offset = clock && Math.abs((Date.now() - clock.wall) - (performance.now() - clock.mono)) <= 1_000
|
|
159
|
+
? clock.offset : undefined;
|
|
160
|
+
const age = Date.now() + (offset ?? 0) - Date.parse(event.created_at);
|
|
161
|
+
if (form && age > (offset === undefined ? FORM_STALE_LOCAL_MS : FORM_STALE_MS)) {
|
|
162
|
+
// The record is acknowledged: no answer reaches Pachca now, and the employee may have sent the form again.
|
|
163
|
+
process.stderr.write(`Отправка формы ${name} пропущена: срок ответа вышел, сотрудник уже видел ошибку отправки\n`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const payload = withFreshTimestamp(event.payload);
|
|
167
|
+
const body = JSON.stringify(payload);
|
|
168
|
+
// The record id stays the same for every attempt and every run, like the key of one webhook delivery.
|
|
169
|
+
const headers = { 'content-type': 'application/json', 'idempotency-key': event.id };
|
|
170
|
+
if (secret)
|
|
171
|
+
headers['pachca-signature'] = createHmac('sha256', secret).update(body).digest('hex');
|
|
172
|
+
let response;
|
|
173
|
+
try {
|
|
174
|
+
response = await fetch(target, {
|
|
175
|
+
method: 'POST', headers, body, redirect: 'manual',
|
|
176
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
if (signal.aborted)
|
|
181
|
+
return;
|
|
182
|
+
throw new Error(`Не удалось переслать событие ${name}, запись сохранена в истории`);
|
|
183
|
+
}
|
|
184
|
+
if (form && (response.status === 400 || response.status === 422)) {
|
|
185
|
+
const errors = await this.formErrors(response);
|
|
186
|
+
if (!errors)
|
|
187
|
+
throw new Error(`Обработчик ответил HTTP ${response.status} на форму ${name} без объекта errors, запись сохранена в истории`);
|
|
188
|
+
await this.answerForm(form, errors);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
await response.body?.cancel();
|
|
192
|
+
if (!response.ok)
|
|
193
|
+
throw new Error(`Обработчик ответил HTTP ${response.status} на событие ${name}, запись сохранена в истории`);
|
|
194
|
+
if (form)
|
|
195
|
+
await this.answerForm(form);
|
|
196
|
+
}
|
|
197
|
+
/** `errors` from a 400 or 422 reply: field name to the text shown under that field. */
|
|
198
|
+
async formErrors(response) {
|
|
199
|
+
try {
|
|
200
|
+
const body = await response.json();
|
|
201
|
+
const errors = body?.errors;
|
|
202
|
+
if (!errors || typeof errors !== 'object' || Array.isArray(errors))
|
|
203
|
+
return undefined;
|
|
204
|
+
const entries = Object.entries(errors);
|
|
205
|
+
if (!entries.length || !entries.every(([, value]) => typeof value === 'string'))
|
|
206
|
+
return undefined;
|
|
207
|
+
return Object.fromEntries(entries);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* A bot without a webhook URL closes the form with a method: the reply a handler gives to a
|
|
215
|
+
* webhook goes on to Pachca the same way, closing the form or showing errors under its fields.
|
|
216
|
+
* The handler has already acted on the submission, and the 5 seconds for an answer do not come
|
|
217
|
+
* back, so a refused answer is reported and the record is still acknowledged.
|
|
218
|
+
*/
|
|
219
|
+
async answerForm(form, errors) {
|
|
220
|
+
if (!this.answerForms)
|
|
221
|
+
return;
|
|
222
|
+
// 503: Pachca kept the submission open, so the same call a moment later can still close the form.
|
|
223
|
+
// Repeating is safe: an answer already taken, or one past the 5 seconds, gets 410.
|
|
224
|
+
for (let attempt = 0;; attempt++) {
|
|
225
|
+
try {
|
|
226
|
+
await this.apiRequest({
|
|
227
|
+
method: 'POST',
|
|
228
|
+
path: `/views/${encodeURIComponent(form.view_id)}/submit_response`,
|
|
229
|
+
body: { submit_id: form.submit_id, ...(errors ? { errors } : {}) },
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (!(error instanceof ApiError))
|
|
235
|
+
throw error;
|
|
236
|
+
if (error.details.code === 503 && attempt < 2) {
|
|
237
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const reason = error.details.code === 410
|
|
241
|
+
? 'время вышло или ответ уже отправлен'
|
|
242
|
+
: `HTTP ${error.details.code ?? 'без ответа'}: ${error.details.error}`;
|
|
243
|
+
process.stderr.write(`Ответ на форму ${form.submit_id} не передан в Пачку: ${reason}\n`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -22,14 +22,14 @@ export default class Doctor extends BaseCommand {
|
|
|
22
22
|
let networkOk = false;
|
|
23
23
|
// 1. Node.js version
|
|
24
24
|
const nodeVersion = process.version.replace('v', '');
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
checks.push({ name: 'node', status: 'ok', version: nodeVersion, required: '>=20' });
|
|
28
|
-
this.printCheck('ok', `Node.js v${nodeVersion} (требуется >=20)`, format);
|
|
25
|
+
// AbortSignal.any (bots listen-events --forward-to) arrived in Node 20.3.0.
|
|
26
|
+
if (semver.satisfies(nodeVersion, '>=20.3.0')) {
|
|
27
|
+
checks.push({ name: 'node', status: 'ok', version: nodeVersion, required: '>=20.3.0' });
|
|
28
|
+
this.printCheck('ok', `Node.js v${nodeVersion} (требуется >=20.3.0)`, format);
|
|
29
29
|
}
|
|
30
30
|
else {
|
|
31
|
-
checks.push({ name: 'node', status: 'error', version: nodeVersion, required: '>=20' });
|
|
32
|
-
this.printCheck('error', `Node.js v${nodeVersion} — требуется >=20`, format);
|
|
31
|
+
checks.push({ name: 'node', status: 'error', version: nodeVersion, required: '>=20.3.0' });
|
|
32
|
+
this.printCheck('error', `Node.js v${nodeVersion} — требуется >=20.3.0`, format);
|
|
33
33
|
}
|
|
34
34
|
// 2. Network. Check the host the CLI actually talks to: on-premise installs
|
|
35
35
|
// point elsewhere, and probing the cloud there reports a healthy network
|
package/dist/data/changelog.json
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "2026.11.2",
|
|
4
|
+
"date": "10 ноября 2026",
|
|
5
|
+
"changes": [
|
|
6
|
+
{
|
|
7
|
+
"type": "+",
|
|
8
|
+
"command": "bots listen-events",
|
|
9
|
+
"description": "События бота в реальном времени без публичного адреса: JSONL в терминале или пересылка на локальный обработчик, как исходящий вебхук, с подписью, свежим `webhook_timestamp` и ответом на отправку формы. Запись удаляется из истории после ответа 2xx."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"type": "~",
|
|
13
|
+
"description": "Для запуска CLI требуется Node.js 20.3.0 или новее"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
2
17
|
{
|
|
3
18
|
"version": "2026.11.1",
|
|
4
19
|
"date": "9 ноября 2026",
|