@grknbyk/agent-wire 0.4.0 → 0.5.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/src/slack.mjs CHANGED
@@ -1,236 +1,322 @@
1
- // The Slack side: one thin client, three read-only probes that setup and doctor
2
- // both use, and the poll that turns channel history into local inbox items.
3
- import { basename } from 'node:path';
4
- import { readFileSync } from 'node:fs';
5
-
6
- import { paths, readJson, writeJson } from './config.mjs';
7
- import { checkAuthorship } from './identity.mjs';
8
- import { HUMAN_TEXT_CAP, METADATA_EVENT, fromSlackText, parseMessage } from './protocol.mjs';
9
-
10
- const API = 'https://slack.com/api/';
11
- const RATE_LIMITED = 429;
12
- const DEFAULT_RETRY_SECONDS = 5;
13
- const PAGE_LIMIT = 100;
14
- const MAX_PAGES = 10;
15
-
16
- // conversations.* reject a JSON body and chat.postMessage needs one for metadata,
17
- // so the client speaks both and the caller picks per method.
18
- export function slackClient(token) {
19
- const request = async (method, init) => {
20
- const response = await fetch(API + method, {
21
- ...init,
22
- headers: { authorization: `Bearer ${token}`, ...init.headers },
23
- });
24
- if (response.status !== RATE_LIMITED) return response.json();
25
-
26
- const wait = Number(response.headers.get('retry-after') || DEFAULT_RETRY_SECONDS);
27
- await new Promise((done) => setTimeout(done, wait * 1000));
28
- return request(method, init);
29
- };
30
-
31
- return {
32
- form: (method, params) => request(method, {
33
- method: 'POST',
34
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
35
- body: new URLSearchParams(params),
36
- }),
37
- json: (method, body) => request(method, {
38
- method: 'POST',
39
- headers: { 'content-type': 'application/json; charset=utf-8' },
40
- body: JSON.stringify(body),
41
- }),
42
- };
43
- }
44
-
45
- // --- probes: read-only, and the same three answer "is setup done" and "what
46
- // broke". Each returns a verdict plus the reason, never a bare boolean, because
47
- // a spinner that cannot say why it is still spinning is the worst dead-end.
48
-
49
- export async function probeToken(client) {
50
- const result = await client.form('auth.test', {});
51
- if (result.ok) return { ok: true, teamId: result.team_id, botUserId: result.user_id, team: result.team };
52
- return { ok: false, reason: result.error };
53
- }
54
-
55
- export async function probeChannel(client, name) {
56
- const wanted = String(name).replace(/^#/, '').toLowerCase();
57
- let cursor = '';
58
- for (let page = 0; page < MAX_PAGES; page++) {
59
- const result = await client.form('conversations.list', {
60
- types: 'public_channel,private_channel',
61
- exclude_archived: true,
62
- limit: 200,
63
- cursor,
64
- });
65
- if (!result.ok) return { ok: false, reason: result.error };
66
-
67
- const found = result.channels.find((channel) => channel.name.toLowerCase() === wanted);
68
- if (found) return { ok: true, id: found.id, name: found.name, isMember: found.is_member, isPrivate: found.is_private };
69
-
70
- cursor = result.response_metadata?.next_cursor ?? '';
71
- if (!cursor) return { ok: false, reason: 'channel_not_found' };
72
- }
73
- return { ok: false, reason: 'channel_not_found' };
74
- }
75
-
76
- // A public channel we can create and join ourselves, which removes the invite
77
- // step entirely. A private one has to be created by a human and the bot invited,
78
- // because no scope lets an app add itself to a private conversation.
79
- export async function ensureChannel(client, name) {
80
- const existing = await probeChannel(client, name);
81
- if (existing.ok && existing.isMember) return existing;
82
-
83
- if (existing.ok && !existing.isPrivate) {
84
- const joined = await client.form('conversations.join', { channel: existing.id });
85
- if (!joined.ok) return { ok: false, reason: joined.error, id: existing.id };
86
- return { ...existing, isMember: true };
87
- }
88
-
89
- if (existing.ok) return { ok: false, reason: 'needs_invite', id: existing.id, name: existing.name };
90
-
91
- if (existing.reason !== 'channel_not_found') return existing;
92
-
93
- const created = await client.form('conversations.create', { name: String(name).replace(/^#/, ''), is_private: false });
94
- if (!created.ok) return { ok: false, reason: created.error };
95
- return { ok: true, id: created.channel.id, name: created.channel.name, isMember: true, isPrivate: false, created: true };
96
- }
97
-
98
- // --- sending
99
-
100
- // `rendered` is the whole visible message — header line plus body — because that
101
- // is what a human scrolling the channel reads. The signature and routing fields
102
- // travel in metadata, which Slack never renders.
103
- export async function postMessage(client, { channel, rendered, signature, publicKey, from, to, conv, hop }) {
104
- const result = await client.json('chat.postMessage', {
105
- channel,
106
- text: rendered,
107
- metadata: {
108
- event_type: METADATA_EVENT,
109
- event_payload: { v: 1, from, to, conv, hop, sig: signature, key: publicKey },
110
- },
111
- });
112
- if (result.ok) return { ok: true, ts: result.ts };
113
- return { ok: false, reason: result.error };
114
- }
115
-
116
- export async function uploadFile(client, { channel, path, comment }) {
117
- const bytes = readFileSync(path);
118
- const name = basename(path);
119
- const slot = await client.form('files.getUploadURLExternal', { filename: name, length: bytes.length });
120
- if (!slot.ok) return { ok: false, reason: slot.error };
121
-
122
- const upload = await fetch(slot.upload_url, { method: 'POST', body: bytes });
123
- if (!upload.ok) return { ok: false, reason: `upload_failed_http_${upload.status}` };
124
-
125
- const done = await client.form('files.completeUploadExternal', {
126
- files: JSON.stringify([{ id: slot.file_id, title: name }]),
127
- channel_id: channel,
128
- initial_comment: comment,
129
- });
130
- return done.ok ? { ok: true } : { ok: false, reason: done.error };
131
- }
132
-
133
- // --- polling
134
-
135
- async function resolveUserName(client, userId) {
136
- const cached = readJson(paths.users, {});
137
- if (cached[userId]) return cached[userId];
138
-
139
- const result = await client.form('users.info', { user: userId });
140
- const name = result.ok ? (result.user.profile?.display_name || result.user.real_name || userId) : userId;
141
- writeJson(paths.users, { ...cached, [userId]: name });
142
- return name;
143
- }
144
-
145
- // A human typing in the channel is worth seeing but is never a directive: it is
146
- // capped, marked, and handed to the agent as data. Slack's own user id is the
147
- // identity here that field is not writable by the person typing.
148
- async function humanItem(client, message, channel) {
149
- const typed = fromSlackText(message.text ?? '').trim();
150
- if (!typed) return null;
151
-
152
- const text = typed.length > HUMAN_TEXT_CAP
153
- ? `${typed.slice(0, HUMAN_TEXT_CAP)}\n... ${typed.length - HUMAN_TEXT_CAP} more characters truncated`
154
- : typed;
155
- return {
156
- ts: message.ts,
157
- at: new Date(Number(message.ts) * 1000).toISOString(),
158
- channel: channel.name,
159
- channelId: channel.id,
160
- from: await resolveUserName(client, message.user),
161
- userId: message.user,
162
- kind: 'human',
163
- authorship: 'slack-verified',
164
- hop: 1,
165
- text,
166
- };
167
- }
168
-
169
- function agentItem(message, channel, payload) {
170
- const parsed = parseMessage(message.text ?? '');
171
- const text = parsed?.text ?? fromSlackText(message.text ?? '');
172
- const authorship = checkAuthorship({
173
- from: payload.from,
174
- publicKey: payload.key,
175
- signature: payload.sig,
176
- channel: channel.id,
177
- to: payload.to,
178
- conv: payload.conv,
179
- hop: payload.hop,
180
- text,
181
- });
182
-
183
- return {
184
- ts: message.ts,
185
- at: new Date(Number(message.ts) * 1000).toISOString(),
186
- channel: channel.name,
187
- channelId: channel.id,
188
- from: payload.from,
189
- to: payload.to,
190
- kind: 'agent',
191
- authorship: authorship.verdict,
192
- conv: payload.conv,
193
- hop: Number(payload.hop) || 1,
194
- text,
195
- };
196
- }
197
-
198
- // Returns items oldest-first. With `oldest` set, Slack answers from the old end of
199
- // the range, so messages[0] is the high-water mark and a burst wider than one page
200
- // is carried across polls rather than dropped.
201
- export async function pollChannel(client, channel, { since, myNickname }) {
202
- const items = [];
203
- let newest = since;
204
- let cursor = '';
205
-
206
- for (let page = 0; page < MAX_PAGES; page++) {
207
- const history = await client.form('conversations.history', {
208
- channel: channel.id,
209
- limit: PAGE_LIMIT,
210
- ...(since ? { oldest: since } : {}),
211
- ...(cursor ? { cursor } : {}),
212
- });
213
- if (!history.ok) return { ok: false, reason: history.error, items: [] };
214
-
215
- for (const message of history.messages.slice().reverse()) {
216
- if (message.subtype) continue;
217
- if (!newest || Number(message.ts) > Number(newest)) newest = message.ts;
218
-
219
- const payload = message.metadata?.event_type === METADATA_EVENT ? message.metadata.event_payload : null;
220
- if (payload) {
221
- if (payload.from === myNickname) continue; // our own post, already in our log
222
- items.push(agentItem(message, channel, payload));
223
- continue;
224
- }
225
- if (message.bot_id) continue; // another app, or one of our own header-only posts
226
-
227
- const human = await humanItem(client, message, channel);
228
- if (human) items.push(human);
229
- }
230
-
231
- cursor = history.response_metadata?.next_cursor ?? '';
232
- if (!history.has_more || !cursor) break;
233
- }
234
-
235
- return { ok: true, items, newest };
236
- }
1
+ // The Slack side: one thin client, the probes that setup and doctor both use, and
2
+ // the poll that turns channel history into local inbox items.
3
+ import { basename, join } from 'node:path';
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+
6
+ import { paths, readJsonCached, writeJson } from './config.mjs';
7
+ import { checkAuthorship } from './identity.mjs';
8
+ import { HUMAN_TEXT_CAP, METADATA_EVENT, fromSlackText, parseMessage } from './protocol.mjs';
9
+
10
+ const API = 'https://slack.com/api/';
11
+ const RATE_LIMITED = 429;
12
+ const DEFAULT_RETRY_SECONDS = 5;
13
+ const PAGE_LIMIT = 100;
14
+ const MAX_PAGES = 10;
15
+ const MEMBER_LIMIT = 200;
16
+ const FILE_SHARE = 'file_share';
17
+
18
+ // Long enough to keep a real document name recognisable, short enough that the
19
+ // Slack file id in front of it still fits a filesystem path.
20
+ const NAME_MAX_CHARS = 80;
21
+
22
+ // Slack serves file bytes over plain HTTP, so an oversized attachment is an
23
+ // oversized write to the user's disk. Past this the file stays in Slack and the
24
+ // message says why it was left there.
25
+ const DOWNLOAD_MAX_BYTES = 20 * 1024 * 1024;
26
+
27
+ // conversations.* reject a JSON body and chat.postMessage needs one for metadata,
28
+ // so the client speaks both and the caller picks per method. The token rides along
29
+ // because downloading a file is a plain fetch, not an API call.
30
+ export function slackClient(token) {
31
+ const request = async (method, init) => {
32
+ const response = await fetch(API + method, {
33
+ ...init,
34
+ headers: { authorization: `Bearer ${token}`, ...init.headers },
35
+ });
36
+ if (response.status !== RATE_LIMITED) return response.json();
37
+
38
+ const wait = Number(response.headers.get('retry-after') || DEFAULT_RETRY_SECONDS);
39
+ await new Promise((done) => setTimeout(done, wait * 1000));
40
+ return request(method, init);
41
+ };
42
+
43
+ return {
44
+ token,
45
+ form: (method, params) => request(method, {
46
+ method: 'POST',
47
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
48
+ body: new URLSearchParams(params),
49
+ }),
50
+ json: (method, body) => request(method, {
51
+ method: 'POST',
52
+ headers: { 'content-type': 'application/json; charset=utf-8' },
53
+ body: JSON.stringify(body),
54
+ }),
55
+ };
56
+ }
57
+
58
+ // --- probes: read-only, and the same ones answer "is setup done" and "what
59
+ // broke". Each returns a verdict plus the reason, never a bare boolean, because
60
+ // a spinner that cannot say why it is still spinning is the worst dead-end.
61
+
62
+ export async function probeToken(client) {
63
+ const result = await client.form('auth.test', {});
64
+ if (result.ok) return { ok: true, teamId: result.team_id, botUserId: result.user_id, team: result.team };
65
+ return { ok: false, reason: result.error };
66
+ }
67
+
68
+ // users.conversations, not conversations.list: the first answers "which channels
69
+ // am I in", the second answers "which channels exist in this workspace". A bridge
70
+ // has no business asking the second one, so it never does. The invite is the whole
71
+ // access control, and it is a human who types it.
72
+ export async function probeChannel(client, name) {
73
+ const wanted = String(name).replace(/^#/, '').toLowerCase();
74
+ let cursor = '';
75
+ for (let page = 0; page < MAX_PAGES; page++) {
76
+ const result = await client.form('users.conversations', {
77
+ types: 'public_channel',
78
+ exclude_archived: true,
79
+ limit: MEMBER_LIMIT,
80
+ cursor,
81
+ });
82
+ if (!result.ok) return { ok: false, reason: result.error };
83
+
84
+ const found = result.channels.find((channel) => channel.name.toLowerCase() === wanted);
85
+ if (found) return { ok: true, id: found.id, name: found.name };
86
+
87
+ cursor = result.response_metadata?.next_cursor ?? '';
88
+ if (!cursor) return { ok: false, reason: 'needs_invite' };
89
+ }
90
+ return { ok: false, reason: 'needs_invite' };
91
+ }
92
+
93
+ // Everyone in one channel the bot was invited to. No workspace directory call
94
+ // exists anywhere in the package, so an invite is the only way a name reaches it.
95
+ export async function listMembers(client, channelId) {
96
+ const result = await client.form('conversations.members', { channel: channelId, limit: MEMBER_LIMIT });
97
+ if (!result.ok) return { ok: false, reason: result.error };
98
+
99
+ return { ok: true, names: await resolveUserNames(client, result.members) };
100
+ }
101
+
102
+ // --- sending
103
+
104
+ // `rendered` is the whole visible message — header line plus body — because that
105
+ // is what a human scrolling the channel reads. The signature and routing fields
106
+ // travel in metadata, which Slack never renders.
107
+ export async function postMessage(client, { channel, rendered, signature, publicKey, from, to, conv, hop, file }) {
108
+ const result = await client.json('chat.postMessage', {
109
+ channel,
110
+ text: rendered,
111
+ metadata: {
112
+ event_type: METADATA_EVENT,
113
+ event_payload: { v: 2, from, to, conv, hop, file: file ?? '', sig: signature, key: publicKey },
114
+ },
115
+ });
116
+ if (result.ok) return { ok: true, ts: result.ts };
117
+ return { ok: false, reason: result.error };
118
+ }
119
+
120
+ // No initial_comment on purpose. files.completeUploadExternal carries no metadata
121
+ // field, so a file posted with its own comment arrives unsigned and cannot be
122
+ // attributed. The caller posts a signed message naming this file id instead.
123
+ export async function uploadFile(client, { channel, path }) {
124
+ const bytes = readFileSync(path);
125
+ const name = basename(path);
126
+ const slot = await client.form('files.getUploadURLExternal', { filename: name, length: bytes.length });
127
+ if (!slot.ok) return { ok: false, reason: slot.error };
128
+
129
+ const upload = await fetch(slot.upload_url, { method: 'POST', body: bytes });
130
+ if (!upload.ok) return { ok: false, reason: `upload_failed_http_${upload.status}` };
131
+
132
+ const done = await client.form('files.completeUploadExternal', {
133
+ files: JSON.stringify([{ id: slot.file_id, title: name }]),
134
+ channel_id: channel,
135
+ });
136
+ return done.ok ? { ok: true, fileId: slot.file_id, name } : { ok: false, reason: done.error };
137
+ }
138
+
139
+ // --- receiving files
140
+
141
+ // The name comes from whoever uploaded the file, so it reaches a path only after
142
+ // the separators are gone. The file id in front of it also keeps two uploads that
143
+ // share a name as two files on disk.
144
+ export const safeName = (name) => String(name ?? '').replace(/[^\w.-]/g, '_').slice(0, NAME_MAX_CHARS) || 'file';
145
+
146
+ // Slack serves the bytes from a URL that needs the bot token, which is exactly
147
+ // what the receiving agent does not have. Pulling them at poll time is what turns
148
+ // "a file was shared" into a path the agent can open.
149
+ async function downloadAttachment(client, file) {
150
+ const source = file.url_private_download ?? file.url_private;
151
+ if (!source) return null;
152
+ if (Number(file.size) > DOWNLOAD_MAX_BYTES) {
153
+ return { name: file.name, path: null, size: Number(file.size), skipped: 'larger than 20 MB' };
154
+ }
155
+
156
+ const response = await fetch(source, { headers: { authorization: `Bearer ${client.token}` } });
157
+ if (!response.ok) {
158
+ return { name: file.name, path: null, size: Number(file.size), skipped: `download failed, HTTP ${response.status}` };
159
+ }
160
+
161
+ mkdirSync(paths.files, { recursive: true });
162
+ const localPath = join(paths.files, `${file.id}-${safeName(file.name)}`);
163
+ writeFileSync(localPath, Buffer.from(await response.arrayBuffer()));
164
+ return { name: file.name, path: localPath, size: Number(file.size) };
165
+ }
166
+
167
+ async function downloadAll(client, files) {
168
+ const saved = [];
169
+ for (const file of files ?? []) {
170
+ const result = await downloadAttachment(client, file);
171
+ if (result) saved.push(result);
172
+ }
173
+ return saved;
174
+ }
175
+
176
+ async function downloadById(client, fileId) {
177
+ const info = await client.form('files.info', { file: fileId });
178
+ if (!info.ok) return [];
179
+
180
+ const saved = await downloadAttachment(client, info.file);
181
+ return saved ? [saved] : [];
182
+ }
183
+
184
+ // --- polling
185
+
186
+ // Resolved in batches because the cache is a file. One member list is 200 ids,
187
+ // and asking one at a time meant 200 reads and 200 rewrites of the same JSON to
188
+ // answer a question about names that almost never change.
189
+ async function resolveUserNames(client, userIds) {
190
+ const known = readJsonCached(paths.users, {});
191
+ const missing = [...new Set(userIds)].filter((userId) => !known[userId]);
192
+ if (missing.length === 0) return userIds.map((userId) => known[userId]);
193
+
194
+ const found = { ...known };
195
+ for (const userId of missing) {
196
+ const result = await client.form('users.info', { user: userId });
197
+ found[userId] = result.ok
198
+ ? (result.user.profile?.display_name || result.user.real_name || userId)
199
+ : userId;
200
+ }
201
+ writeJson(paths.users, found);
202
+ return userIds.map((userId) => found[userId]);
203
+ }
204
+
205
+ // Only the messages that will become human items: an agent post carries its name
206
+ // in the signed payload, and another app's post is dropped before it is read.
207
+ async function namesInPage(client, messages) {
208
+ const userIds = messages
209
+ .filter((message) => message.user && !message.bot_id && message.metadata?.event_type !== METADATA_EVENT)
210
+ .map((message) => message.user);
211
+ const names = await resolveUserNames(client, userIds);
212
+ return new Map(userIds.map((userId, index) => [userId, names[index]]));
213
+ }
214
+
215
+ // A human typing in the channel is worth seeing but is never a directive: it is
216
+ // capped, marked, and handed to the agent as data. Slack's own user id is the
217
+ // identity here that field is not writable by the person typing.
218
+ async function humanItem(client, message, channel, namesById) {
219
+ const typed = fromSlackText(message.text ?? '').trim();
220
+ const files = await downloadAll(client, message.files);
221
+ if (!typed && files.length === 0) return null;
222
+
223
+ const text = typed.length > HUMAN_TEXT_CAP
224
+ ? `${typed.slice(0, HUMAN_TEXT_CAP)}\n... ${typed.length - HUMAN_TEXT_CAP} more characters truncated`
225
+ : typed;
226
+ return {
227
+ ts: message.ts,
228
+ at: new Date(Number(message.ts) * 1000).toISOString(),
229
+ channel: channel.name,
230
+ channelId: channel.id,
231
+ from: namesById.get(message.user) ?? message.user,
232
+ userId: message.user,
233
+ kind: 'human',
234
+ authorship: 'slack-verified',
235
+ hop: 1,
236
+ text: text || `shared ${files.length} file(s)`,
237
+ files,
238
+ };
239
+ }
240
+
241
+ async function agentItem(client, message, channel, payload) {
242
+ const parsed = parseMessage(message.text ?? '');
243
+ const text = parsed?.text ?? fromSlackText(message.text ?? '');
244
+ const authorship = checkAuthorship({
245
+ from: payload.from,
246
+ publicKey: payload.key,
247
+ signature: payload.sig,
248
+ channel: channel.id,
249
+ to: payload.to,
250
+ conv: payload.conv,
251
+ hop: payload.hop,
252
+ file: payload.file ?? '',
253
+ text,
254
+ });
255
+
256
+ // Fetched only after the signature holds. Pulling bytes for a message that
257
+ // failed verification is doing an impostor's downloading for them.
258
+ const isTrusted = authorship.verdict === 'signed' || authorship.verdict === 'new';
259
+ const files = payload.file && isTrusted ? await downloadById(client, payload.file) : [];
260
+
261
+ return {
262
+ ts: message.ts,
263
+ at: new Date(Number(message.ts) * 1000).toISOString(),
264
+ channel: channel.name,
265
+ channelId: channel.id,
266
+ from: payload.from,
267
+ to: payload.to,
268
+ kind: 'agent',
269
+ authorship: authorship.verdict,
270
+ conv: payload.conv,
271
+ hop: Number(payload.hop) || 1,
272
+ text,
273
+ files,
274
+ };
275
+ }
276
+
277
+ // Returns items oldest-first. With `oldest` set, Slack answers from the old end of
278
+ // the range, so messages[0] is the high-water mark and a burst wider than one page
279
+ // is carried across polls rather than dropped.
280
+ export async function pollChannel(client, channel, { since, myNickname }) {
281
+ const items = [];
282
+ let newest = since;
283
+ let cursor = '';
284
+
285
+ for (let page = 0; page < MAX_PAGES; page++) {
286
+ const history = await client.form('conversations.history', {
287
+ channel: channel.id,
288
+ limit: PAGE_LIMIT,
289
+ ...(since ? { oldest: since } : {}),
290
+ ...(cursor ? { cursor } : {}),
291
+ });
292
+ if (!history.ok) return { ok: false, reason: history.error, items: [] };
293
+
294
+ // Every human name in the page, resolved in one go before any item is
295
+ // built. Doing it inside the loop meant one file read per message to
296
+ // answer the same question about the same twenty people.
297
+ const namesById = await namesInPage(client, history.messages);
298
+
299
+ for (const message of history.messages.slice().reverse()) {
300
+ // file_share is the one subtype carrying a real message. The rest are
301
+ // joins, leaves and topic changes, and dropping them is the point.
302
+ if (message.subtype && message.subtype !== FILE_SHARE) continue;
303
+ if (!newest || Number(message.ts) > Number(newest)) newest = message.ts;
304
+
305
+ const payload = message.metadata?.event_type === METADATA_EVENT ? message.metadata.event_payload : null;
306
+ if (payload) {
307
+ if (payload.from === myNickname) continue; // our own post, already in our log
308
+ items.push(await agentItem(client, message, channel, payload));
309
+ continue;
310
+ }
311
+ if (message.bot_id) continue; // another app, or the bare upload our own sidecar describes
312
+
313
+ const human = await humanItem(client, message, channel, namesById);
314
+ if (human) items.push(human);
315
+ }
316
+
317
+ cursor = history.response_metadata?.next_cursor ?? '';
318
+ if (!history.has_more || !cursor) break;
319
+ }
320
+
321
+ return { ok: true, items, newest };
322
+ }