@zksecurity/slack-events 0.1.1 → 0.2.0

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
@@ -57,3 +57,39 @@ npm pack ./packages/slack-events
57
57
  The prepack step compiles only the dependency-free client source. Inspect and
58
58
  test the tarball before publishing it with `npm publish TARBALL` under an
59
59
  authorized npm account. Publishing is independent of the bot's npm package.
60
+
61
+ ## Read Slack context and download files
62
+
63
+ After deploying the matching server and reauthorizing the expanded user scopes:
64
+
65
+ ```sh
66
+ slack-events users
67
+ slack-events channels
68
+ slack-events channels --limit 200 --cursor NEXT_CURSOR
69
+ slack-events user U012345
70
+ slack-events channel C012345
71
+ slack-events message C012345 1788884385.665039
72
+ slack-events history C012345 --limit 50 --cursor NEXT_CURSOR
73
+ slack-events thread C012345 1788881468.871969 --limit 50
74
+ slack-events file F012345
75
+ slack-events download F012345 ./attachment.pdf
76
+ slack-events listen --resolve
77
+ ```
78
+
79
+ 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.
80
+
81
+ Slack identifies a thread by its channel ID and root message timestamp, not a separate thread ID. Use `history CHANNEL_ID` to find a root message's `ts`, then run `thread CHANNEL_ID ROOT_TS`. If you have a reply, its `thread_ts` identifies the root; use `message CHANNEL_ID MESSAGE_TS` to inspect it when needed. Keep timestamps as strings with all decimal digits.
82
+
83
+ Read commands return Slack JSON, including pagination cursors. `message` returns
84
+ one target message and its reactions through `reactions.get`; `thread` takes the
85
+ parent timestamp. Download streams file bytes via the event server using the
86
+ subscriber's user grant, refuses to overwrite existing paths, and writes mode
87
+ 0600. External files without a Slack-hosted download are unsupported. A failed
88
+ transfer may leave a partial output file; remove that file before retrying.
89
+
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.
91
+ No read command changes the ACK cursor. The CLI uses its existing paired feed
92
+ credential; Slack user tokens stay on the server. No bot credential is used.
93
+
94
+ The packaged [slack-events skill](skills/slack-events/SKILL.md) is a manual for
95
+ agents consuming these events and using the CLI.
package/dist/cli.js CHANGED
@@ -1,8 +1,20 @@
1
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { Readable } from "node:stream";
2
+ import { pipeline } from "node:stream/promises";
3
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync, createWriteStream } from "node:fs";
2
4
  import { homedir } from "node:os";
3
5
  import { dirname, join } from "node:path";
4
6
  export async function runSlackEventsCli(args) {
5
- const usage = "Usage: slack-events pair <server> [code] | listen | ack <committed-cursor> | list | revoke <subscription-id>";
7
+ const usage = `Usage: slack-events pair <server> [code] | listen [--resolve] | ack <committed-cursor> | list | revoke <subscription-id>
8
+ users [--limit N] [--cursor C] List users and their IDs
9
+ channels [--limit N] [--cursor C] List conversations and their IDs
10
+ user USER_ID Get user profile/name
11
+ channel CHANNEL_ID Get channel metadata/name
12
+ history CHANNEL_ID [--limit N] [--cursor C]
13
+ thread CHANNEL_ID TIMESTAMP [--limit N] [--cursor C]
14
+ message CHANNEL_ID TIMESTAMP Get one message
15
+ file FILE_ID Get file metadata and Slack links
16
+ download FILE_ID OUTPUT_PATH Download a file (refuses overwrite)
17
+ Read commands print Slack JSON including pagination cursors. They never ACK events.`;
6
18
  const command = args[0];
7
19
  if (!command || command === "--help" || command === "-h") {
8
20
  process.stdout.write(`${usage}\n`);
@@ -19,6 +31,52 @@ export async function runSlackEventsCli(args) {
19
31
  return;
20
32
  }
21
33
  const credential = readCredential();
34
+ if (["user", "users", "channel", "channels", "history", "thread", "message", "file", "download"].includes(command)) {
35
+ const query = new URLSearchParams();
36
+ let position = 1;
37
+ if (command !== "users" && command !== "channels") {
38
+ const id = requireArgument(args[position++], "Slack ID");
39
+ if (!/^[A-Z][A-Z0-9]+$/.test(id))
40
+ throw new Error("Invalid Slack ID");
41
+ query.set("id", id);
42
+ }
43
+ if (command === "thread" || command === "message") {
44
+ const ts = requireArgument(args[position++], "message timestamp");
45
+ if (!/^\d+\.\d+$/.test(ts))
46
+ throw new Error("Invalid Slack timestamp");
47
+ query.set("ts", ts);
48
+ }
49
+ if (command === "download") {
50
+ const output = requireArgument(args[position++], "output path");
51
+ if (position !== args.length)
52
+ throw new Error(usage);
53
+ const response = await fetch(new URL(`/v1/events/slack/download?${query}`, credential.server), {
54
+ headers: { authorization: `Bearer ${credential.token}` }, signal: AbortSignal.timeout(120_000), redirect: "error",
55
+ });
56
+ if (!response.ok) {
57
+ const error = await response.json();
58
+ throw new Error(error.error || `Download failed (${response.status})`);
59
+ }
60
+ if (!response.body)
61
+ throw new Error("Download response has no body");
62
+ // wx prevents overwriting an existing file or following an existing symlink.
63
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(output, { flags: "wx", mode: 0o600 }));
64
+ process.stdout.write(`${output}\n`);
65
+ return;
66
+ }
67
+ while (position < args.length) {
68
+ const option = args[position++];
69
+ if (!["users", "channels", "history", "thread"].includes(command) || !["--limit", "--cursor"].includes(option))
70
+ throw new Error(usage);
71
+ const value = requireArgument(args[position++], option);
72
+ if (option === "--limit" && (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 200))
73
+ throw new Error("limit must be between 1 and 200");
74
+ query.set(option.slice(2), value);
75
+ }
76
+ const result = await request(credential.server, `/v1/events/slack/${command}?${query}`, { token: credential.token });
77
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
78
+ return;
79
+ }
22
80
  if (command === "ack") {
23
81
  const value = requireArgument(args[1], "committed cursor");
24
82
  const cursor = Number(value);
@@ -42,7 +100,9 @@ export async function runSlackEventsCli(args) {
42
100
  return;
43
101
  }
44
102
  if (command === "listen") {
45
- await listen(credential);
103
+ if (args.length > 2 || (args[1] && args[1] !== "--resolve"))
104
+ throw new Error(usage);
105
+ await listen(credential, args[1] === "--resolve");
46
106
  return;
47
107
  }
48
108
  throw new Error(usage);
@@ -76,35 +136,72 @@ async function pairDevice(server) {
76
136
  }
77
137
  throw new Error("Pairing expired. Run the pairing command again for a new link.");
78
138
  }
79
- async function listen(credential) {
139
+ async function listen(credential, resolve = false) {
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 }));
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 }));
80
143
  let after;
81
- for (;;) {
82
- const path = `/v1/events?wait_ms=30000${after === undefined ? "" : `&after=${after}`}`;
83
- const response = await request(credential.server, path, { token: credential.token });
84
- const events = Array.isArray(response.events) ? response.events : [];
85
- for (const event of events)
86
- await writeStdout(`${JSON.stringify(event)}\n`);
87
- if (events.length > 0) {
88
- const cursor = events.at(-1).sequence;
89
- if (!Number.isSafeInteger(cursor))
90
- throw new Error("Event feed returned an invalid cursor");
91
- // This is only an in-process read position. The recipient explicitly
92
- // ACKs after its own durable commit; stdout never advances that checkpoint.
93
- after = cursor;
144
+ try {
145
+ for (;;) {
146
+ if (resolve)
147
+ void names.refresh();
148
+ const path = `/v1/events?wait_ms=30000${after === undefined ? "" : `&after=${after}`}`;
149
+ const response = await request(credential.server, path, { token: credential.token });
150
+ const events = Array.isArray(response.events) ? response.events : [];
151
+ 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));
157
+ const root = await threadRoot(requireObject(event));
158
+ if (root) {
159
+ const rootNames = names.enrich({ event: root }).context.users;
160
+ const bot = requireObject(root.bot_profile ?? {});
161
+ enriched.context.threadRoot = { ts: root.ts, user: root.user, text: root.text,
162
+ author: typeof root.user === "string" ? rootNames[root.user] ?? bot.name : bot.name };
163
+ }
164
+ const payload = requireObject(enriched.event ?? {});
165
+ if (payload.type === "file_shared") {
166
+ try {
167
+ const file = requireObject(payload.file ?? {});
168
+ const id = requireString(payload.file_id ?? file.id, "file ID");
169
+ const result = await request(credential.server, `/v1/events/slack/file?${new URLSearchParams({ id })}`, { token: credential.token, timeoutMs: 2_000 });
170
+ enriched.context.files.push(requireObject(result.file));
171
+ }
172
+ catch (error) {
173
+ process.stderr.write(`File lookup unavailable: ${error instanceof Error ? error.message : "request failed"}\n`);
174
+ }
175
+ }
176
+ await writeStdout(`${JSON.stringify(enriched)}\n`);
177
+ }
178
+ if (events.length > 0) {
179
+ const cursor = events.at(-1).sequence;
180
+ if (!Number.isSafeInteger(cursor))
181
+ throw new Error("Event feed returned an invalid cursor");
182
+ // This is only an in-process read position. The recipient explicitly
183
+ // ACKs after its own durable commit; stdout never advances that checkpoint.
184
+ after = cursor;
185
+ }
94
186
  }
95
187
  }
188
+ finally {
189
+ metadataAbort.abort();
190
+ }
96
191
  }
97
192
  async function request(server, path, options = {}) {
193
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? 40_000);
98
194
  const response = await fetch(new URL(path, `${normalizeServer(server)}/`), {
99
- signal: AbortSignal.timeout(40_000),
195
+ signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout,
100
196
  method: options.method || "GET",
197
+ redirect: "error",
101
198
  headers: {
102
199
  ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
103
200
  ...(options.body ? { "content-type": "application/json" } : {}),
104
201
  },
105
202
  body: options.body ? JSON.stringify(options.body) : undefined,
106
203
  });
107
- const body = await response.json();
204
+ const body = requireObject(await response.json());
108
205
  if (!response.ok)
109
206
  throw new Error(typeof body.error === "string" ? body.error : `Event server returned ${response.status}`);
110
207
  return body;
@@ -181,3 +278,131 @@ function writeStdout(text) {
181
278
  }
182
279
  });
183
280
  }
281
+ /** A whole-directory snapshot belongs to one listener and its credential. */
282
+ export function createNameResolver(fetchPage) {
283
+ let users = new Map();
284
+ let channels = new Map();
285
+ let refreshing;
286
+ let nextRefresh = 0;
287
+ async function readPages(operation, accept) {
288
+ let cursor = "";
289
+ const seen = new Set();
290
+ do {
291
+ seen.add(cursor);
292
+ const page = await fetchPage(operation, cursor);
293
+ const entries = page[operation === "users" ? "members" : "channels"];
294
+ if (!Array.isArray(entries))
295
+ throw new Error(`Invalid Slack ${operation} list`);
296
+ for (const entry of entries)
297
+ accept(requireObject(entry));
298
+ const metadata = requireObject(page.response_metadata ?? {});
299
+ cursor = metadata.next_cursor === undefined ? "" : requireCursor(metadata.next_cursor);
300
+ if (cursor && seen.has(cursor))
301
+ throw new Error("Slack directory repeated a pagination cursor");
302
+ } while (cursor);
303
+ }
304
+ async function refresh() {
305
+ try {
306
+ const nextUsers = new Map();
307
+ const nextChannels = new Map();
308
+ await readPages("users", user => {
309
+ const profile = requireObject(user.profile ?? {});
310
+ const name = profile.display_name || user.real_name || user.name;
311
+ if (typeof name === "string" && name)
312
+ nextUsers.set(requireString(user.id, "user ID"), name);
313
+ });
314
+ await readPages("channels", channel => {
315
+ const id = requireString(channel.id, "channel ID");
316
+ const user = channel.is_im && typeof channel.user === "string" ? channel.user : undefined;
317
+ const name = user && nextUsers.has(user) ? `DM with ${nextUsers.get(user)}` : channel.name;
318
+ if (typeof name === "string" && name)
319
+ nextChannels.set(id, { name, user });
320
+ });
321
+ // Publish only a complete snapshot; failed refreshes retain the previous one.
322
+ users = nextUsers;
323
+ channels = nextChannels;
324
+ }
325
+ catch (error) {
326
+ process.stderr.write(`Name refresh unavailable: ${error instanceof Error ? error.message : "request failed"}\n`);
327
+ }
328
+ }
329
+ return {
330
+ refresh() {
331
+ if (refreshing)
332
+ return refreshing;
333
+ if (Date.now() < nextRefresh)
334
+ return Promise.resolve();
335
+ nextRefresh = Date.now() + 6 * 60 * 60_000;
336
+ refreshing = refresh().finally(() => { refreshing = undefined; });
337
+ return refreshing;
338
+ },
339
+ enrich(delivery) {
340
+ const event = requireObject(delivery.event ?? {});
341
+ const message = requireObject(event.message ?? event.previous_message ?? event);
342
+ const item = requireObject(event.item ?? {});
343
+ const channelId = event.channel ?? item.channel ?? event.channel_id;
344
+ const channel = typeof channelId === "string" ? channels.get(channelId) : undefined;
345
+ const context = { users: {}, channels: {}, files: [] };
346
+ if (channel && typeof channelId === "string")
347
+ context.channels[channelId] = channel.name;
348
+ const ids = new Set([event.user, message.user, event.item_user, channel?.user,
349
+ ...Array.from(String(message.text ?? "").matchAll(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g), match => match[1])]);
350
+ for (const id of ids) {
351
+ if (typeof id !== "string")
352
+ continue;
353
+ const name = users.get(id);
354
+ if (name)
355
+ context.users[id] = name;
356
+ }
357
+ return { ...delivery, context };
358
+ },
359
+ };
360
+ }
361
+ function requireObject(value) {
362
+ if (!value || typeof value !== "object" || Array.isArray(value))
363
+ throw new Error("Expected a JSON object");
364
+ return value;
365
+ }
366
+ function requireCursor(value) {
367
+ if (typeof value !== "string")
368
+ throw new Error("Invalid pagination cursor");
369
+ return value;
370
+ }
371
+ /** Cache thread roots per listener; a failed lookup must not prevent delivery. */
372
+ export function createThreadResolver(fetchRoot) {
373
+ const cache = new Map();
374
+ const put = (key, root) => {
375
+ if (cache.size >= 1000)
376
+ cache.delete(cache.keys().next().value);
377
+ cache.set(key, { until: Date.now() + (root ? 3600_000 : 60_000), root });
378
+ };
379
+ return async (delivery) => {
380
+ const event = requireObject(delivery.event ?? {});
381
+ if (event.type !== "message" || typeof event.channel !== "string")
382
+ return undefined;
383
+ const message = requireObject(event.message ?? event.previous_message ?? event);
384
+ const ts = event.deleted_ts ?? message.ts;
385
+ const rootTs = message.thread_ts;
386
+ if (typeof rootTs !== "string" || rootTs === ts) {
387
+ if (typeof ts === "string")
388
+ put(`${event.channel}/${ts}`, event.subtype === "message_deleted" ? undefined : message);
389
+ return undefined;
390
+ }
391
+ const key = `${event.channel}/${rootTs}`;
392
+ const cached = cache.get(key);
393
+ if (cached && cached.until > Date.now())
394
+ return cached.root;
395
+ try {
396
+ const response = await fetchRoot(event.channel, rootTs);
397
+ const root = requireObject(response.message);
398
+ if (root.ts !== rootTs)
399
+ throw new Error("Thread root timestamp mismatch");
400
+ put(key, root);
401
+ return root;
402
+ }
403
+ catch {
404
+ put(key);
405
+ return undefined;
406
+ }
407
+ };
408
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@zksecurity/slack-events",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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" },
7
- "files": ["bin", "dist"],
7
+ "files": ["bin", "dist", "skills"],
8
8
  "engines": { "node": ">=24" },
9
9
  "license": "MIT",
10
10
  "repository": {
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: slack-events
3
+ description: Read personal Slack events, resolve Slack user/channel IDs, inspect message or thread context, and retrieve shared files with the paired slack-events CLI.
4
+ ---
5
+
6
+ # Personal Slack events
7
+
8
+ Use `slack-events` on the paired machine. Run `slack-events --help` to inspect
9
+ installed commands. The CLI uses its saved feed credential; the server makes
10
+ read-only Slack calls under that subscriber's user OAuth grant. It cannot send
11
+ messages or modify Slack. Commands requiring added scopes need server deployment
12
+ and user reauthorization before they work.
13
+
14
+ ## Reading incoming events
15
+
16
+ `listen` emits JSONL with `sequence`, stable `eventId`, `teamId`, `eventType`,
17
+ `eventTime`, `receivedAt`, and the inner Slack `event`. `listen --resolve` adds
18
+ `context.users`, `context.channels`, and file metadata for `file_shared` events.
19
+ For replies, optional `context.threadRoot` supplies the root timestamp, user,
20
+ author name, and text. Root lookups have a two-second deadline and are cached;
21
+ 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.
27
+ Standalone file metadata is fetched separately with a two-second deadline;
28
+ failure leaves the original event intact and `context.files` empty.
29
+
30
+ A message can include `files` (uploaded files) and `attachments` (structured
31
+ attachments or link previews). A `message_changed` event contains the current
32
+ `message` and often `previous_message`; a deletion may include `previous_message`.
33
+ A `file_shared` event may contain only `file_id` and `channel_id`: use `file` or
34
+ `download` below. A file-only message can have empty text.
35
+
36
+ Reactions use `reaction_added` / `reaction_removed`: `user` is the actor,
37
+ `reaction` is the emoji name (including custom emoji), and `item` identifies the
38
+ message or file. The event timestamp is not the target message timestamp.
39
+
40
+ Event-time snapshots can differ from the current Slack content returned by read
41
+ commands. The CLI emits the original event, not a rendered message or summary.
42
+
43
+ ## Context and names
44
+
45
+ Use `users` and `channels` to discover IDs by inspecting their names before making
46
+ individual reads. Results are in `members` and `channels` respectively. Directory
47
+ commands take no ID and return one page; follow `response_metadata.next_cursor`
48
+ with `--cursor` until empty. `list` lists feed subscriptions, not conversations.
49
+
50
+ There is no separate thread ID: a thread is identified by its channel ID and root
51
+ message timestamp. Use `history CHANNEL_ID` to find a root message's `ts`, then
52
+ pass that timestamp to `thread`. For a reply, use its `thread_ts` instead.
53
+
54
+ ```sh
55
+ slack-events users
56
+ slack-events channels
57
+ slack-events users --limit 200 --cursor NEXT_CURSOR
58
+ slack-events user U012345
59
+ slack-events channel C012345
60
+ slack-events message C012345 1788884385.665039
61
+ slack-events thread C012345 1788881468.871969 --limit 50
62
+ slack-events history C012345 --limit 50
63
+ slack-events history C012345 --limit 50 --cursor NEXT_CURSOR
64
+ ```
65
+
66
+ Keep timestamps as strings with every decimal digit. `message` uses
67
+ `reactions.get` to return the target message and reactions; its `message.thread_ts`
68
+ identifies the parent if it is a reply. Use that timestamp with `thread` for
69
+ surrounding context. If a reaction only supplies the target timestamp, inspect
70
+ `message` before assuming the target is a thread root.
71
+
72
+ A Slack message permalink encodes the channel and message timestamp for CLI
73
+ lookup (`p1788884385665039` corresponds to `1788884385.665039`).
74
+
75
+ Read commands print Slack JSON. For `users`, `channels`, `history`, and `thread`, follow
76
+ `response_metadata.next_cursor` with `--cursor`; one page is not necessarily the
77
+ complete history. `users:read` enables user lookups. Channel metadata requires
78
+ the appropriate `*:read` scope. History uses the existing `*:history` scopes.
79
+
80
+ ## Files
81
+
82
+ ```sh
83
+ slack-events file F012345
84
+ slack-events download F012345 ./attachment.pdf
85
+ ```
86
+
87
+ `file` returns metadata such as filename/title, MIME type, size, and Slack links.
88
+ `download` streams the bytes to the explicit output path and refuses to overwrite
89
+ an existing file. These commands require `files:read`. Files removed from Slack,
90
+ external files without a downloadable URL, or files no longer accessible to the
91
+ subscriber may not be downloadable. A private Slack URL is not a public link;
92
+ use the authenticated CLI download rather than unauthenticated curl.
93
+
94
+ ## Feed lifecycle
95
+
96
+ ```sh
97
+ slack-events pair https://YOUR-EVENT-HOST
98
+ slack-events listen --resolve
99
+ slack-events list
100
+ ```
101
+
102
+ Pairing opens a browser authorization flow and stores the credential on the
103
+ originating machine. Re-pair after new user scopes are granted. The credential
104
+ lives at `$XDG_CONFIG_HOME/slack-events/credentials.json`, defaulting to
105
+ `~/.config/slack-events/credentials.json`; do not print it.
106
+
107
+ Reading or printing does not acknowledge. A durable consumer deduplicates by
108
+ `eventId`, commits all deliveries through a sequence, then calls
109
+ `slack-events ack SEQUENCE`. Never ACK a cursor just to inspect events. Only one
110
+ acknowledging consumer should use a paired credential; unacknowledged events
111
+ replay on restart subject to server retention. `revoke SUBSCRIPTION_ID` disables
112
+ a subscription; use it only when asked to revoke access.