@zksecurity/slack-events 0.2.1 → 0.2.3

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
@@ -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. It fetches a complete user and conversation name snapshot in the background at startup and every six hours, caching the entire snapshot in memory until the next successful refresh. Failed refreshes keep the previous snapshot; unknown names fall back to IDs without blocking delivery. Restarting the listener fetches a fresh snapshot. 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.
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
- const response = await request(credential.server, path, { token: credential.token });
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 response = await fetch(new URL(path, `${normalizeServer(server)}/`), {
190
- signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout,
191
- method: options.method || "GET",
192
- redirect: "error",
193
- headers: {
194
- ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
195
- ...(options.body ? { "content-type": "application/json" } : {}),
196
- },
197
- body: options.body ? JSON.stringify(options.body) : undefined,
198
- });
199
- const body = requireObject(await response.json());
200
- if (!response.ok)
201
- throw new Error(typeof body.error === "string" ? body.error : `Event server returned ${response.status}`);
202
- return body;
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,21 +411,31 @@ 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
- const channel = typeof channelId === "string" ? channels.get(channelId) : undefined;
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;
343
- const ids = new Set([event.user, message.user, event.item_user, channel?.user,
433
+ const ids = new Set([event.user, event.user_id, message.user, event.item_user, channel?.user,
344
434
  ...Array.from(String(message.text ?? "").matchAll(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g), match => match[1])]);
345
435
  for (const id of ids) {
346
436
  if (typeof id !== "string")
347
437
  continue;
348
- const name = users.get(id);
438
+ const name = await userName(id);
349
439
  if (name)
350
440
  context.users[id] = name;
351
441
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zksecurity/slack-events",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Pair a terminal with a pi-mom Slack event feed",
5
5
  "type": "module",
6
6
  "bin": { "slack-events": "bin/slack-events.mjs" },
@@ -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 fetches a complete user and
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