@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 +36 -0
- package/dist/cli.js +244 -19
- package/package.json +2 -2
- package/skills/slack-events/SKILL.md +112 -0
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
await
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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.
|
|
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.
|