@zksecurity/slack-events 0.2.1 → 0.2.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 +5 -1
- package/dist/cli.js +112 -22
- package/package.json +1 -1
- package/skills/slack-events/SKILL.md +5 -5
package/README.md
CHANGED
|
@@ -18,6 +18,10 @@ Pairing does not start a listener. On the same machine, run:
|
|
|
18
18
|
npx @zksecurity/slack-events listen
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
The server filters events, including backlog, to conversations you have joined. Membership is cached for one minute, but new events from channels absent from the snapshot trigger a fresh check before filtering. Events without an identifiable joined channel are omitted.
|
|
22
|
+
|
|
23
|
+
`listen` retries temporary feed failures with exponential backoff from one to thirty seconds and honors longer `Retry-After` delays. Retries do not advance the read cursor or ACK events. Invalid or revoked feed credentials stop the listener. Other commands do not retry automatically.
|
|
24
|
+
|
|
21
25
|
`listen` stays running and prints one JSON object per event from your personal
|
|
22
26
|
feed, including available backlog and new arrivals. It uses the saved credential;
|
|
23
27
|
no URL or browser login is needed. Stop with Ctrl+C and run `listen` again later
|
|
@@ -87,7 +91,7 @@ subscriber's user grant, refuses to overwrite existing paths, and writes mode
|
|
|
87
91
|
0600. External files without a Slack-hosted download are unsupported. A failed
|
|
88
92
|
transfer may leave a partial output file; remove that file before retrying.
|
|
89
93
|
|
|
90
|
-
`listen` adds `context.users` / `context.channels` name maps while preserving the original event and cursor.
|
|
94
|
+
`listen` adds `context.users` / `context.channels` name maps while preserving the original event and cursor. The listener waits for the initial user/channel snapshot before polling events, then refreshes it in the background every six hours. Missing names use individual `user` / `channel` lookups with a two-second deadline per request. Successful lookups are cached for six hours; failed lookups and directory refreshes retry after a minute. Failed refreshes retain the previous snapshot. Inaccessible names can remain unresolved; original IDs stay in the JSON. Standalone file shares also get `context.files` metadata, fetched separately with a two-second deadline; failure leaves the original event intact and the files list empty. Use `file` or `download` to retry retrieving the file.
|
|
91
95
|
No read command changes the ACK cursor. The CLI uses its existing paired feed
|
|
92
96
|
credential; Slack user tokens stay on the server. No bot credential is used.
|
|
93
97
|
|
package/dist/cli.js
CHANGED
|
@@ -138,20 +138,35 @@ async function pairDevice(server) {
|
|
|
138
138
|
}
|
|
139
139
|
async function listen(credential) {
|
|
140
140
|
const metadataAbort = new AbortController();
|
|
141
|
-
const names = createNameResolver((operation, cursor) => request(credential.server, `/v1/events/slack/${operation}?${new URLSearchParams({ limit: "200", cursor })}`, { token: credential.token, signal: metadataAbort.signal }));
|
|
141
|
+
const names = createNameResolver((operation, cursor) => request(credential.server, `/v1/events/slack/${operation}?${new URLSearchParams({ limit: "200", cursor })}`, { token: credential.token, signal: metadataAbort.signal }), (operation, id) => request(credential.server, `/v1/events/slack/${operation}?${new URLSearchParams({ id })}`, { token: credential.token, timeoutMs: 2_000, signal: metadataAbort.signal }));
|
|
142
142
|
const threadRoot = createThreadResolver((channel, ts) => request(credential.server, `/v1/events/slack/message?${new URLSearchParams({ id: channel, ts })}`, { token: credential.token, timeoutMs: 2_000, signal: metadataAbort.signal }));
|
|
143
143
|
let after;
|
|
144
|
+
let retryDelayMs = 1_000;
|
|
144
145
|
try {
|
|
146
|
+
await names.refresh();
|
|
145
147
|
for (;;) {
|
|
146
148
|
void names.refresh();
|
|
147
149
|
const path = `/v1/events?wait_ms=30000${after === undefined ? "" : `&after=${after}`}`;
|
|
148
|
-
|
|
150
|
+
let response;
|
|
151
|
+
try {
|
|
152
|
+
response = await request(credential.server, path, { token: credential.token });
|
|
153
|
+
retryDelayMs = 1_000;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (!(error instanceof RetryableRequestError))
|
|
157
|
+
throw error;
|
|
158
|
+
const delay = Math.max(retryDelayMs, error.retryAfterMs);
|
|
159
|
+
process.stderr.write(`Event feed unavailable: ${error.message}. Retrying in ${Math.ceil(delay / 1000)} seconds.\n`);
|
|
160
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
161
|
+
retryDelayMs = Math.min(retryDelayMs * 2, 30_000);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
149
164
|
const events = Array.isArray(response.events) ? response.events : [];
|
|
150
165
|
for (const event of events) {
|
|
151
|
-
const enriched = names.enrich(requireObject(event));
|
|
166
|
+
const enriched = await names.enrich(requireObject(event));
|
|
152
167
|
const root = await threadRoot(requireObject(event));
|
|
153
168
|
if (root) {
|
|
154
|
-
const rootNames = names.enrich({ event: root }).context.users;
|
|
169
|
+
const rootNames = (await names.enrich({ event: root })).context.users;
|
|
155
170
|
const bot = requireObject(root.bot_profile ?? {});
|
|
156
171
|
enriched.context.threadRoot = { ts: root.ts, user: root.user, text: root.text,
|
|
157
172
|
author: typeof root.user === "string" ? rootNames[root.user] ?? bot.name : bot.name };
|
|
@@ -184,22 +199,47 @@ async function listen(credential) {
|
|
|
184
199
|
metadataAbort.abort();
|
|
185
200
|
}
|
|
186
201
|
}
|
|
202
|
+
class RetryableRequestError extends Error {
|
|
203
|
+
retryAfterMs;
|
|
204
|
+
constructor(message, retryAfterMs) {
|
|
205
|
+
super(message);
|
|
206
|
+
this.retryAfterMs = retryAfterMs;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
187
209
|
async function request(server, path, options = {}) {
|
|
188
210
|
const timeout = AbortSignal.timeout(options.timeoutMs ?? 40_000);
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
211
|
+
const url = new URL(path, `${normalizeServer(server)}/`);
|
|
212
|
+
try {
|
|
213
|
+
const response = await fetch(url, {
|
|
214
|
+
signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout,
|
|
215
|
+
method: options.method || "GET",
|
|
216
|
+
redirect: "error",
|
|
217
|
+
headers: {
|
|
218
|
+
...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
|
|
219
|
+
...(options.body ? { "content-type": "application/json" } : {}),
|
|
220
|
+
},
|
|
221
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
222
|
+
});
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
// Proxies can return HTML errors; the HTTP status still determines retryability.
|
|
225
|
+
const body = await response.json().catch(() => undefined);
|
|
226
|
+
const message = typeof body?.error === "string" ? body.error : `Event server returned ${response.status}`;
|
|
227
|
+
if (response.status === 408 || response.status === 429 || response.status >= 500) {
|
|
228
|
+
const header = response.headers.get("retry-after") ?? "";
|
|
229
|
+
const delay = /^\d+$/.test(header) ? Number(header) * 1000 : Date.parse(header) - Date.now();
|
|
230
|
+
throw new RetryableRequestError(message, Number.isFinite(delay) ? Math.max(0, delay) : 0);
|
|
231
|
+
}
|
|
232
|
+
throw new Error(message);
|
|
233
|
+
}
|
|
234
|
+
return requireObject(await response.json());
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (error instanceof TypeError || (error instanceof Error &&
|
|
238
|
+
(error.name === "TimeoutError" || (error.name === "AbortError" && timeout.aborted)))) {
|
|
239
|
+
throw new RetryableRequestError(error.message, 0);
|
|
240
|
+
}
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
203
243
|
}
|
|
204
244
|
function credentialPath() {
|
|
205
245
|
const configRoot = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
@@ -274,11 +314,50 @@ function writeStdout(text) {
|
|
|
274
314
|
});
|
|
275
315
|
}
|
|
276
316
|
/** A whole-directory snapshot belongs to one listener and its credential. */
|
|
277
|
-
export function createNameResolver(fetchPage) {
|
|
317
|
+
export function createNameResolver(fetchPage, fetchOne) {
|
|
278
318
|
let users = new Map();
|
|
279
319
|
let channels = new Map();
|
|
280
320
|
let refreshing;
|
|
281
321
|
let nextRefresh = 0;
|
|
322
|
+
const lookups = new Map();
|
|
323
|
+
async function lookup(operation, id) {
|
|
324
|
+
if (!fetchOne)
|
|
325
|
+
return undefined;
|
|
326
|
+
const key = `${operation}/${id}`;
|
|
327
|
+
const cached = lookups.get(key);
|
|
328
|
+
if (cached && cached.until > Date.now())
|
|
329
|
+
return cached.result;
|
|
330
|
+
const entry = { until: Infinity, result: Promise.resolve(undefined) };
|
|
331
|
+
entry.result = (async () => {
|
|
332
|
+
try {
|
|
333
|
+
const item = requireObject((await fetchOne(operation, id))[operation]);
|
|
334
|
+
if (item.id !== id)
|
|
335
|
+
throw new Error("Slack lookup returned a different ID");
|
|
336
|
+
entry.until = Date.now() + 6 * 60 * 60_000;
|
|
337
|
+
return item;
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
entry.until = Date.now() + 60_000;
|
|
341
|
+
process.stderr.write(`Name lookup unavailable: ${error instanceof Error ? error.message : "request failed"}\n`);
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
})();
|
|
345
|
+
if (lookups.size >= 2_000)
|
|
346
|
+
lookups.delete(lookups.keys().next().value);
|
|
347
|
+
lookups.set(key, entry);
|
|
348
|
+
return entry.result;
|
|
349
|
+
}
|
|
350
|
+
async function userName(id) {
|
|
351
|
+
const known = users.get(id);
|
|
352
|
+
if (known)
|
|
353
|
+
return known;
|
|
354
|
+
const user = await lookup("user", id);
|
|
355
|
+
if (!user)
|
|
356
|
+
return undefined;
|
|
357
|
+
const profile = requireObject(user.profile ?? {});
|
|
358
|
+
const name = profile.display_name || user.real_name || user.name;
|
|
359
|
+
return typeof name === "string" && name ? name : undefined;
|
|
360
|
+
}
|
|
282
361
|
async function readPages(operation, accept) {
|
|
283
362
|
let cursor = "";
|
|
284
363
|
const seen = new Set();
|
|
@@ -318,6 +397,7 @@ export function createNameResolver(fetchPage) {
|
|
|
318
397
|
channels = nextChannels;
|
|
319
398
|
}
|
|
320
399
|
catch (error) {
|
|
400
|
+
nextRefresh = Date.now() + 60_000;
|
|
321
401
|
process.stderr.write(`Name refresh unavailable: ${error instanceof Error ? error.message : "request failed"}\n`);
|
|
322
402
|
}
|
|
323
403
|
}
|
|
@@ -331,12 +411,22 @@ export function createNameResolver(fetchPage) {
|
|
|
331
411
|
refreshing = refresh().finally(() => { refreshing = undefined; });
|
|
332
412
|
return refreshing;
|
|
333
413
|
},
|
|
334
|
-
enrich(delivery) {
|
|
414
|
+
async enrich(delivery) {
|
|
335
415
|
const event = requireObject(delivery.event ?? {});
|
|
336
416
|
const message = requireObject(event.message ?? event.previous_message ?? event);
|
|
337
417
|
const item = requireObject(event.item ?? {});
|
|
338
418
|
const channelId = event.channel ?? item.channel ?? event.channel_id;
|
|
339
|
-
|
|
419
|
+
let channel = typeof channelId === "string" ? channels.get(channelId) : undefined;
|
|
420
|
+
if (!channel && typeof channelId === "string") {
|
|
421
|
+
const info = await lookup("channel", channelId);
|
|
422
|
+
if (info) {
|
|
423
|
+
const user = info.is_im && typeof info.user === "string" ? info.user : undefined;
|
|
424
|
+
const peer = user ? await userName(user) : undefined;
|
|
425
|
+
const name = peer ? `DM with ${peer}` : info.name;
|
|
426
|
+
if (typeof name === "string" && name)
|
|
427
|
+
channel = { name, user };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
340
430
|
const context = { users: {}, channels: {}, files: [] };
|
|
341
431
|
if (channel && typeof channelId === "string")
|
|
342
432
|
context.channels[channelId] = channel.name;
|
|
@@ -345,7 +435,7 @@ export function createNameResolver(fetchPage) {
|
|
|
345
435
|
for (const id of ids) {
|
|
346
436
|
if (typeof id !== "string")
|
|
347
437
|
continue;
|
|
348
|
-
const name =
|
|
438
|
+
const name = await userName(id);
|
|
349
439
|
if (name)
|
|
350
440
|
context.users[id] = name;
|
|
351
441
|
}
|
package/package.json
CHANGED
|
@@ -13,17 +13,17 @@ and user reauthorization before they work.
|
|
|
13
13
|
|
|
14
14
|
## Reading incoming events
|
|
15
15
|
|
|
16
|
+
The server filters the feed to conversations you have joined, including backlog. Membership is cached for one minute, but new events from channels absent from the snapshot trigger a fresh check before filtering. Events without an identifiable joined channel are omitted.
|
|
17
|
+
|
|
18
|
+
`listen` retries temporary feed failures with exponential backoff from one to thirty seconds and honors longer `Retry-After` delays. Retries do not advance the read cursor or ACK events. Invalid or revoked feed credentials stop the listener. Other commands do not retry automatically.
|
|
19
|
+
|
|
16
20
|
`listen` emits JSONL with `sequence`, stable `eventId`, `teamId`, `eventType`,
|
|
17
21
|
`eventTime`, `receivedAt`, and the inner Slack `event`. `listen` adds
|
|
18
22
|
`context.users`, `context.channels`, and file metadata for `file_shared` events.
|
|
19
23
|
For replies, optional `context.threadRoot` supplies the root timestamp, user,
|
|
20
24
|
author name, and text. Root lookups have a two-second deadline and are cached;
|
|
21
25
|
a missing or inaccessible root leaves that field absent.
|
|
22
|
-
The listener
|
|
23
|
-
conversation name snapshot in the background at startup and every six hours,
|
|
24
|
-
keeping the whole snapshot in memory between refreshes. Failed refreshes retain
|
|
25
|
-
the last good snapshot. Names are resolved locally; unknown IDs remain IDs and
|
|
26
|
-
never hold up event delivery. IDs remain in the CLI JSON. Restarting the listener fetches a fresh snapshot.
|
|
26
|
+
The listener waits for the initial user/channel snapshot before polling events, then refreshes it in the background every six hours. Missing names use individual `user` / `channel` lookups with a two-second deadline per request. Successful lookups are cached for six hours; failed lookups and directory refreshes retry after a minute. Failed refreshes retain the previous snapshot. Inaccessible names can remain unresolved; original IDs stay in the JSON.
|
|
27
27
|
Standalone file metadata is fetched separately with a two-second deadline;
|
|
28
28
|
failure leaves the original event intact and `context.files` empty.
|
|
29
29
|
|