@zksecurity/slack-events 0.2.0 → 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 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
@@ -73,7 +77,7 @@ slack-events history C012345 --limit 50 --cursor NEXT_CURSOR
73
77
  slack-events thread C012345 1788881468.871969 --limit 50
74
78
  slack-events file F012345
75
79
  slack-events download F012345 ./attachment.pdf
76
- slack-events listen --resolve
80
+ slack-events listen
77
81
  ```
78
82
 
79
83
  Use `users` and `channels` to discover IDs before making individual reads. They return Slack JSON with `members` or `channels` arrays containing IDs and names. `channels` includes public/private channels and direct/group conversations available to your user grant. Follow `response_metadata.next_cursor` with `--cursor` until it is empty; each call returns one page, and `--limit` accepts 1-200. The existing `list` command lists feed subscriptions, not Slack conversations.
@@ -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 --resolve` 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
@@ -4,7 +4,7 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync, createWriteStream }
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  export async function runSlackEventsCli(args) {
7
- const usage = `Usage: slack-events pair <server> [code] | listen [--resolve] | ack <committed-cursor> | list | revoke <subscription-id>
7
+ const usage = `Usage: slack-events pair <server> [code] | listen | ack <committed-cursor> | list | revoke <subscription-id>
8
8
  users [--limit N] [--cursor C] List users and their IDs
9
9
  channels [--limit N] [--cursor C] List conversations and their IDs
10
10
  user USER_ID Get user profile/name
@@ -100,9 +100,9 @@ Read commands print Slack JSON including pagination cursors. They never ACK even
100
100
  return;
101
101
  }
102
102
  if (command === "listen") {
103
- if (args.length > 2 || (args[1] && args[1] !== "--resolve"))
103
+ if (args.length !== 1)
104
104
  throw new Error(usage);
105
- await listen(credential, args[1] === "--resolve");
105
+ await listen(credential);
106
106
  return;
107
107
  }
108
108
  throw new Error(usage);
@@ -136,27 +136,37 @@ async function pairDevice(server) {
136
136
  }
137
137
  throw new Error("Pairing expired. Run the pairing command again for a new link.");
138
138
  }
139
- async function listen(credential, resolve = false) {
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
- if (resolve)
147
- void names.refresh();
148
+ void names.refresh();
148
149
  const path = `/v1/events?wait_ms=30000${after === undefined ? "" : `&after=${after}`}`;
149
- 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
+ }
150
164
  const events = Array.isArray(response.events) ? response.events : [];
151
165
  for (const event of events) {
152
- if (!resolve) {
153
- await writeStdout(`${JSON.stringify(event)}\n`);
154
- continue;
155
- }
156
- const enriched = names.enrich(requireObject(event));
166
+ const enriched = await names.enrich(requireObject(event));
157
167
  const root = await threadRoot(requireObject(event));
158
168
  if (root) {
159
- const rootNames = names.enrich({ event: root }).context.users;
169
+ const rootNames = (await names.enrich({ event: root })).context.users;
160
170
  const bot = requireObject(root.bot_profile ?? {});
161
171
  enriched.context.threadRoot = { ts: root.ts, user: root.user, text: root.text,
162
172
  author: typeof root.user === "string" ? rootNames[root.user] ?? bot.name : bot.name };
@@ -189,22 +199,47 @@ async function listen(credential, resolve = false) {
189
199
  metadataAbort.abort();
190
200
  }
191
201
  }
202
+ class RetryableRequestError extends Error {
203
+ retryAfterMs;
204
+ constructor(message, retryAfterMs) {
205
+ super(message);
206
+ this.retryAfterMs = retryAfterMs;
207
+ }
208
+ }
192
209
  async function request(server, path, options = {}) {
193
210
  const timeout = AbortSignal.timeout(options.timeoutMs ?? 40_000);
194
- const response = await fetch(new URL(path, `${normalizeServer(server)}/`), {
195
- signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout,
196
- method: options.method || "GET",
197
- redirect: "error",
198
- headers: {
199
- ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
200
- ...(options.body ? { "content-type": "application/json" } : {}),
201
- },
202
- body: options.body ? JSON.stringify(options.body) : undefined,
203
- });
204
- const body = requireObject(await response.json());
205
- if (!response.ok)
206
- throw new Error(typeof body.error === "string" ? body.error : `Event server returned ${response.status}`);
207
- 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
+ }
208
243
  }
209
244
  function credentialPath() {
210
245
  const configRoot = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
@@ -279,11 +314,50 @@ function writeStdout(text) {
279
314
  });
280
315
  }
281
316
  /** A whole-directory snapshot belongs to one listener and its credential. */
282
- export function createNameResolver(fetchPage) {
317
+ export function createNameResolver(fetchPage, fetchOne) {
283
318
  let users = new Map();
284
319
  let channels = new Map();
285
320
  let refreshing;
286
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
+ }
287
361
  async function readPages(operation, accept) {
288
362
  let cursor = "";
289
363
  const seen = new Set();
@@ -323,6 +397,7 @@ export function createNameResolver(fetchPage) {
323
397
  channels = nextChannels;
324
398
  }
325
399
  catch (error) {
400
+ nextRefresh = Date.now() + 60_000;
326
401
  process.stderr.write(`Name refresh unavailable: ${error instanceof Error ? error.message : "request failed"}\n`);
327
402
  }
328
403
  }
@@ -336,12 +411,22 @@ export function createNameResolver(fetchPage) {
336
411
  refreshing = refresh().finally(() => { refreshing = undefined; });
337
412
  return refreshing;
338
413
  },
339
- enrich(delivery) {
414
+ async enrich(delivery) {
340
415
  const event = requireObject(delivery.event ?? {});
341
416
  const message = requireObject(event.message ?? event.previous_message ?? event);
342
417
  const item = requireObject(event.item ?? {});
343
418
  const channelId = event.channel ?? item.channel ?? event.channel_id;
344
- 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
+ }
345
430
  const context = { users: {}, channels: {}, files: [] };
346
431
  if (channel && typeof channelId === "string")
347
432
  context.channels[channelId] = channel.name;
@@ -350,7 +435,7 @@ export function createNameResolver(fetchPage) {
350
435
  for (const id of ids) {
351
436
  if (typeof id !== "string")
352
437
  continue;
353
- const name = users.get(id);
438
+ const name = await userName(id);
354
439
  if (name)
355
440
  context.users[id] = name;
356
441
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zksecurity/slack-events",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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
- `eventTime`, `receivedAt`, and the inner Slack `event`. `listen --resolve` adds
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
 
@@ -95,7 +95,7 @@ use the authenticated CLI download rather than unauthenticated curl.
95
95
 
96
96
  ```sh
97
97
  slack-events pair https://YOUR-EVENT-HOST
98
- slack-events listen --resolve
98
+ slack-events listen
99
99
  slack-events list
100
100
  ```
101
101