@pachca/cli 2026.11.0 → 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.
@@ -0,0 +1,25 @@
1
+ import { ApiError, request } from './client.js';
2
+ /**
3
+ * Whom a token belongs to: name, email and whether it is a bot.
4
+ *
5
+ * Reading the profile needs `profile:read`. Without it the user id from the
6
+ * token info is all there is: enough to name the account, not to tell a bot
7
+ * from an employee, so `isBot` is then null. A bot token starts with no scopes,
8
+ * so this is the usual case for a notification bot.
9
+ */
10
+ export async function fetchIdentity(token, userId, clientFlags = { quiet: true }) {
11
+ try {
12
+ const response = await request({ method: 'GET', path: '/profile', token }, clientFlags);
13
+ const data = response.data.data;
14
+ const name = `${data.first_name || ''} ${data.last_name || ''}`.trim();
15
+ return {
16
+ name: name || `User #${userId}`,
17
+ email: data.email || null,
18
+ isBot: data.bot || false,
19
+ };
20
+ }
21
+ catch (error) {
22
+ const noScope = error instanceof ApiError && error.details.type === 'PACHCA_SCOPE_ERROR';
23
+ return { name: `User #${userId}`, email: null, isBot: null, noScope };
24
+ }
25
+ }
@@ -0,0 +1,262 @@
1
+ import { EventSource } from 'eventsource';
2
+ import { ApiError } from './client.js';
3
+ /** A failure worth reconnecting over: no answer, a timeout, a throttle or a server error. */
4
+ function isTransient(error) {
5
+ if (!(error instanceof ApiError))
6
+ return false;
7
+ const code = error.details.code;
8
+ return code === null || code === 429 || (code ?? 0) >= 500;
9
+ }
10
+ // Without acknowledgements the history only grows, so a re-read stops at records older than
11
+ // the newest one already handled, minus this margin for records committed out of order.
12
+ const REREAD_MARGIN_MS = 5 * 60_000;
13
+ function eventRecord(value) {
14
+ const event = value;
15
+ if (!event || typeof event.id !== 'string' || !event.id ||
16
+ typeof event.event_type !== 'string' || typeof event.created_at !== 'string' ||
17
+ !Object.hasOwn(event, 'payload')) {
18
+ throw new Error('Некорректная запись истории событий');
19
+ }
20
+ return event;
21
+ }
22
+ /** A single serial consumer for history and live events, including overlapping connections. */
23
+ export async function listenWebhookEvents(options) {
24
+ const { request, signal } = options;
25
+ if (signal.aborted)
26
+ return;
27
+ const timeoutMs = options.timeoutMs ?? 30_000;
28
+ const processed = new Set();
29
+ let newest;
30
+ const connections = new Map();
31
+ let current;
32
+ let candidate;
33
+ let connecting = false;
34
+ // A failure closes every link and starts over: a connect() begun before it must not open one more.
35
+ let generation = 0;
36
+ let stopped = false;
37
+ let reconnect;
38
+ let queue = Promise.resolve();
39
+ let resolve;
40
+ let reject;
41
+ const finished = new Promise((ok, fail) => { resolve = ok; reject = fail; });
42
+ function close(stream) {
43
+ const timers = connections.get(stream);
44
+ clearTimeout(timers?.renew);
45
+ clearTimeout(timers?.confirm);
46
+ connections.delete(stream);
47
+ stream.close();
48
+ }
49
+ function stop(error) {
50
+ if (stopped)
51
+ return;
52
+ stopped = true;
53
+ clearTimeout(reconnect);
54
+ for (const stream of connections.keys())
55
+ close(stream);
56
+ signal.removeEventListener('abort', onAbort);
57
+ if (error)
58
+ reject(error);
59
+ else
60
+ resolve();
61
+ }
62
+ const onAbort = () => stop();
63
+ signal.addEventListener('abort', onAbort, { once: true });
64
+ function enqueue(work) {
65
+ queue = queue.then(async () => { if (!stopped)
66
+ await work(); }).catch(fail);
67
+ }
68
+ // A transient API failure while reading history or acknowledging is a lost connection:
69
+ // reconnect, and the next confirmation re-reads the history, where unacknowledged records wait.
70
+ function fail(error) {
71
+ if (stopped)
72
+ return;
73
+ if (!isTransient(error) || options.retry === false) {
74
+ stop(error);
75
+ return;
76
+ }
77
+ generation++;
78
+ // Including a stream being replaced: its successor failed before closing it.
79
+ for (const stream of [...connections.keys()])
80
+ close(stream);
81
+ current = undefined;
82
+ candidate = undefined;
83
+ connecting = false;
84
+ retry();
85
+ }
86
+ async function consume(event) {
87
+ if (stopped || processed.has(event.id))
88
+ return;
89
+ await options.onEvent(event);
90
+ if (stopped)
91
+ return;
92
+ if (options.acknowledge) {
93
+ try {
94
+ await request({ method: 'DELETE', path: `/webhooks/events/${encodeURIComponent(event.id)}` });
95
+ }
96
+ catch (error) {
97
+ // 404: already deleted (a retried DELETE whose first attempt went through, or another consumer).
98
+ if (!(error instanceof ApiError && error.details.code === 404))
99
+ throw error;
100
+ }
101
+ }
102
+ processed.add(event.id);
103
+ const created = Date.parse(event.created_at);
104
+ if (Number.isFinite(created) && (newest === undefined || created > newest))
105
+ newest = created;
106
+ // Bound memory in an indefinitely running observer. Consumers still need payload idempotency.
107
+ if (processed.size > 10_000)
108
+ processed.delete(processed.values().next().value);
109
+ }
110
+ async function readHistory() {
111
+ const records = [];
112
+ const cursors = new Set();
113
+ let cursor;
114
+ do {
115
+ if (stopped)
116
+ return;
117
+ const body = await request({
118
+ method: 'GET', path: '/webhooks/events', query: { limit: 50, ...(cursor ? { cursor } : {}) },
119
+ });
120
+ if (!Array.isArray(body?.data))
121
+ throw new Error('Некорректный ответ истории событий');
122
+ const page = body.data.map(eventRecord);
123
+ records.push(...page);
124
+ if (!body.meta?.paginate?.has_next)
125
+ break;
126
+ // History comes newest first. Records already printed stay in it, so stop once a page
127
+ // reaches past the newest handled one. With acknowledgements the history holds only the rest.
128
+ const oldest = Date.parse(page.at(-1)?.created_at ?? '');
129
+ if (!options.acknowledge && newest !== undefined && oldest < newest - REREAD_MARGIN_MS)
130
+ break;
131
+ cursor = body.meta.paginate.next_page;
132
+ if (!cursor || cursors.has(cursor))
133
+ throw new Error('Некорректная пагинация истории событий');
134
+ cursors.add(cursor);
135
+ } while (true);
136
+ for (const record of records.reverse())
137
+ await consume(record);
138
+ }
139
+ function retry() {
140
+ if (stopped || connecting || reconnect)
141
+ return;
142
+ if (options.retry === false) {
143
+ stop(new Error('Соединение с потоком событий прервано'));
144
+ return;
145
+ }
146
+ options.onReconnect?.();
147
+ reconnect = setTimeout(() => {
148
+ reconnect = undefined;
149
+ void connect();
150
+ }, 5_000);
151
+ }
152
+ async function connect() {
153
+ if (stopped || connecting)
154
+ return;
155
+ connecting = true;
156
+ const started = generation;
157
+ try {
158
+ const body = await request({ method: 'GET', path: '/webhooks/events/stream' });
159
+ if (stopped || started !== generation)
160
+ return;
161
+ let url;
162
+ const expires = Date.parse(body?.data?.expires_at);
163
+ try {
164
+ url = new URL(body?.data?.url);
165
+ }
166
+ catch {
167
+ throw new Error('Некорректная ссылка потока событий');
168
+ }
169
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password ||
170
+ !Number.isFinite(expires) || expires <= Date.now()) {
171
+ throw new Error('Некорректная ссылка потока событий');
172
+ }
173
+ // Never pass the API bearer token or log this capability URL, even on fetch errors.
174
+ const stream = new EventSource(url, {
175
+ fetch: (input, init) => fetch(input, { ...init, redirect: 'error' }),
176
+ });
177
+ let ready = false;
178
+ const pending = [];
179
+ function lost() {
180
+ if (!connections.has(stream))
181
+ return;
182
+ const wasCurrent = current === stream;
183
+ const wasCandidate = candidate === stream;
184
+ close(stream);
185
+ if (wasCurrent)
186
+ current = undefined;
187
+ if (wasCandidate) {
188
+ candidate = undefined;
189
+ connecting = false;
190
+ }
191
+ if (wasCurrent || wasCandidate)
192
+ retry();
193
+ }
194
+ const timers = {
195
+ renew: setTimeout(() => { if (current === stream)
196
+ void connect(); }, Math.max(1_000, expires - Date.now() - 300_000)),
197
+ confirm: setTimeout(lost, timeoutMs),
198
+ };
199
+ candidate = stream;
200
+ connections.set(stream, timers);
201
+ stream.addEventListener('message', (message) => {
202
+ try {
203
+ const event = eventRecord(JSON.parse(message.data));
204
+ if (ready)
205
+ enqueue(() => consume(event));
206
+ else
207
+ pending.push(event);
208
+ }
209
+ catch {
210
+ stop(new Error('Некорректное событие в потоке'));
211
+ }
212
+ });
213
+ stream.addEventListener('confirm_subscription', () => {
214
+ clearTimeout(timers.confirm);
215
+ timers.confirm = undefined;
216
+ if (candidate === stream) {
217
+ candidate = undefined;
218
+ connecting = false;
219
+ }
220
+ ready = false;
221
+ const previous = current;
222
+ current = stream;
223
+ enqueue(async () => {
224
+ await readHistory();
225
+ // Events keep arriving while earlier ones are handled: drain until the buffer is empty.
226
+ while (pending.length)
227
+ await consume(pending.shift());
228
+ ready = true;
229
+ if (previous && previous !== stream)
230
+ close(previous);
231
+ });
232
+ });
233
+ stream.addEventListener('disconnect', lost);
234
+ stream.addEventListener('error', () => {
235
+ ready = false;
236
+ if (options.retry === false || stream.readyState === EventSource.CLOSED) {
237
+ lost();
238
+ return;
239
+ }
240
+ // EventSource retries network failures itself; confirmation triggers another history read.
241
+ // The deadline is armed once per unconfirmed period, so endless failures end in a new link.
242
+ timers.confirm ??= setTimeout(lost, timeoutMs);
243
+ });
244
+ }
245
+ catch (error) {
246
+ if (started !== generation)
247
+ return;
248
+ connecting = false;
249
+ if (isTransient(error))
250
+ retry();
251
+ else {
252
+ if (error instanceof ApiError && error.details.code === 404) {
253
+ error.details.hint = 'Нужен токен бота с включённой историей событий: ' +
254
+ '«Сохранять историю событий» в настройках бота или events_history_enabled';
255
+ }
256
+ stop(error);
257
+ }
258
+ }
259
+ }
260
+ void connect();
261
+ await finished;
262
+ }