@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/README.md +276 -243
- package/bin/agent-wire.mjs +127 -115
- package/manifest.json +31 -38
- package/package.json +3 -2
- package/src/config.mjs +114 -68
- package/src/identity.mjs +101 -73
- package/src/inbox.mjs +99 -76
- package/src/mcp.mjs +382 -309
- package/src/protocol.mjs +77 -68
- package/src/setup.mjs +171 -243
- package/src/slack.mjs +322 -236
package/src/slack.mjs
CHANGED
|
@@ -1,236 +1,322 @@
|
|
|
1
|
-
// The Slack side: one thin client,
|
|
2
|
-
//
|
|
3
|
-
import { basename } from 'node:path';
|
|
4
|
-
import { readFileSync } from 'node:fs';
|
|
5
|
-
|
|
6
|
-
import { paths,
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
headers: {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
+
}
|