agent-slack 0.2.12
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/LICENSE +21 -0
- package/README.md +247 -0
- package/bin/agent-slack.js +2 -0
- package/dist/index.js +3706 -0
- package/dist/index.js.map +52 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3706 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
|
|
7
|
+
// src/lib/version.ts
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
var cachedVersion;
|
|
12
|
+
function getPackageVersion() {
|
|
13
|
+
if (cachedVersion !== undefined) {
|
|
14
|
+
return cachedVersion;
|
|
15
|
+
}
|
|
16
|
+
if (typeof AGENT_SLACK_BUILD_VERSION === "string" && AGENT_SLACK_BUILD_VERSION) {
|
|
17
|
+
cachedVersion = AGENT_SLACK_BUILD_VERSION;
|
|
18
|
+
return cachedVersion;
|
|
19
|
+
}
|
|
20
|
+
const envVersion = process.env.AGENT_SLACK_VERSION?.trim() || process.env.npm_package_version?.trim();
|
|
21
|
+
if (envVersion) {
|
|
22
|
+
cachedVersion = envVersion;
|
|
23
|
+
return cachedVersion;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
for (let i = 0;i < 6; i++) {
|
|
28
|
+
const candidate = join(dir, "package.json");
|
|
29
|
+
if (existsSync(candidate)) {
|
|
30
|
+
const raw = readFileSync(candidate, "utf8");
|
|
31
|
+
const pkg = JSON.parse(raw);
|
|
32
|
+
const v = typeof pkg.version === "string" ? pkg.version.trim() : "";
|
|
33
|
+
cachedVersion = v || "0.0.0";
|
|
34
|
+
return cachedVersion;
|
|
35
|
+
}
|
|
36
|
+
const next = dirname(dir);
|
|
37
|
+
if (next === dir) {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
dir = next;
|
|
41
|
+
}
|
|
42
|
+
} catch {}
|
|
43
|
+
cachedVersion = "0.0.0";
|
|
44
|
+
return cachedVersion;
|
|
45
|
+
}
|
|
46
|
+
function getUserAgent() {
|
|
47
|
+
return `agent-slack/${getPackageVersion()}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/auth/chrome.ts
|
|
51
|
+
import { execSync } from "node:child_process";
|
|
52
|
+
import { platform } from "node:os";
|
|
53
|
+
var IS_MACOS = platform() === "darwin";
|
|
54
|
+
function escapeOsaScript(script) {
|
|
55
|
+
return script.replace(/'/g, `'"'"'`);
|
|
56
|
+
}
|
|
57
|
+
function osascript(script) {
|
|
58
|
+
return execSync(`osascript -e '${escapeOsaScript(script)}'`, {
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
timeout: 7000,
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
62
|
+
}).trim();
|
|
63
|
+
}
|
|
64
|
+
function cookieScript() {
|
|
65
|
+
return `
|
|
66
|
+
tell application "Google Chrome"
|
|
67
|
+
repeat with w in windows
|
|
68
|
+
repeat with t in tabs of w
|
|
69
|
+
if URL of t contains "slack.com" then
|
|
70
|
+
return execute t javascript "document.cookie.split('; ').find(c => c.startsWith('d='))?.split('=')[1] || ''"
|
|
71
|
+
end if
|
|
72
|
+
end repeat
|
|
73
|
+
end repeat
|
|
74
|
+
return ""
|
|
75
|
+
end tell
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
var TEAM_JSON_PATHS = [
|
|
79
|
+
"JSON.stringify(JSON.parse(localStorage.localConfig_v2).teams)",
|
|
80
|
+
"JSON.stringify(JSON.parse(localStorage.localConfig_v3).teams)",
|
|
81
|
+
"JSON.stringify(JSON.parse(localStorage.getItem('reduxPersist:localConfig'))?.teams || {})",
|
|
82
|
+
"JSON.stringify(window.boot_data?.teams || {})"
|
|
83
|
+
];
|
|
84
|
+
function isRecord(value) {
|
|
85
|
+
return typeof value === "object" && value !== null;
|
|
86
|
+
}
|
|
87
|
+
function toChromeTeam(value) {
|
|
88
|
+
if (!isRecord(value)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const token = typeof value.token === "string" ? value.token : null;
|
|
92
|
+
const url = typeof value.url === "string" ? value.url : null;
|
|
93
|
+
if (!token || !url || !token.startsWith("xoxc-")) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const name = typeof value.name === "string" ? value.name : undefined;
|
|
97
|
+
return { url, name, token };
|
|
98
|
+
}
|
|
99
|
+
function teamsScript() {
|
|
100
|
+
const tryPaths = TEAM_JSON_PATHS.map((expr) => `try { var v = ${expr}; if (v && v !== '{}' && v !== 'null') return v; } catch(e) {}`);
|
|
101
|
+
return `
|
|
102
|
+
tell application "Google Chrome"
|
|
103
|
+
repeat with w in windows
|
|
104
|
+
repeat with t in tabs of w
|
|
105
|
+
if URL of t contains "slack.com" then
|
|
106
|
+
return execute t javascript "(function(){ ${tryPaths.join(" ")} return '{}'; })()"
|
|
107
|
+
end if
|
|
108
|
+
end repeat
|
|
109
|
+
end repeat
|
|
110
|
+
return "{}"
|
|
111
|
+
end tell
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
function extractFromChrome() {
|
|
115
|
+
if (!IS_MACOS) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const cookie = osascript(cookieScript());
|
|
120
|
+
if (!cookie || !cookie.startsWith("xoxd-")) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const teamsRaw = osascript(teamsScript());
|
|
124
|
+
let teamsObj = {};
|
|
125
|
+
try {
|
|
126
|
+
teamsObj = JSON.parse(teamsRaw || "{}");
|
|
127
|
+
} catch {
|
|
128
|
+
teamsObj = {};
|
|
129
|
+
}
|
|
130
|
+
const teamsRecord = isRecord(teamsObj) ? teamsObj : {};
|
|
131
|
+
const teams = Object.values(teamsRecord).map((t) => toChromeTeam(t)).filter((t) => t !== null);
|
|
132
|
+
if (teams.length === 0) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return { cookie_d: cookie, teams };
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/auth/curl.ts
|
|
142
|
+
function parseSlackCurlCommand(curlInput) {
|
|
143
|
+
const urlMatch = curlInput.match(/curl\s+['"]?(https?:\/\/([^.]+)\.slack\.com[^'"\s]*)/i);
|
|
144
|
+
if (!urlMatch) {
|
|
145
|
+
throw new Error("Could not find Slack workspace URL in cURL command");
|
|
146
|
+
}
|
|
147
|
+
const workspace_url = `https://${urlMatch[2]}.slack.com`;
|
|
148
|
+
const cookieMatch = curlInput.match(/(?:-b|--cookie)\s+\$?'([^']+)'|(?:-b|--cookie)\s+\$?"([^"]+)"|-H\s+\$?'[Cc]ookie:\s*([^']+)'|-H\s+\$?"[Cc]ookie:\s*([^"]+)"/);
|
|
149
|
+
const cookieHeader = cookieMatch ? cookieMatch[1] || cookieMatch[2] || cookieMatch[3] || cookieMatch[4] || "" : "";
|
|
150
|
+
const xoxdMatch = cookieHeader.match(/(?:^|;\s*)d=(xoxd-[^;]+)/);
|
|
151
|
+
if (!xoxdMatch) {
|
|
152
|
+
throw new Error("Could not find xoxd cookie (d=xoxd-...) in cURL command");
|
|
153
|
+
}
|
|
154
|
+
const xoxd_cookie = decodeURIComponent(xoxdMatch[1]);
|
|
155
|
+
const tokenPatterns = [
|
|
156
|
+
/(?:^|[?&\s])token=(xoxc-[A-Za-z0-9-]+)/,
|
|
157
|
+
/"token"\s*:\s*"(xoxc-[A-Za-z0-9-]+)"/,
|
|
158
|
+
/name="token"[^x]*?(xoxc-[A-Za-z0-9-]+)/,
|
|
159
|
+
/\b(xoxc-[A-Za-z0-9-]+)\b/
|
|
160
|
+
];
|
|
161
|
+
let xoxc_token = null;
|
|
162
|
+
for (const re of tokenPatterns) {
|
|
163
|
+
const m = curlInput.match(re);
|
|
164
|
+
if (m?.[1]) {
|
|
165
|
+
const [, token] = m;
|
|
166
|
+
xoxc_token = token ?? null;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!xoxc_token) {
|
|
171
|
+
throw new Error("Could not find xoxc token in cURL command");
|
|
172
|
+
}
|
|
173
|
+
return { workspace_url, xoxc_token, xoxd_cookie };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/auth/desktop.ts
|
|
177
|
+
import { cp, mkdir, rm, unlink } from "node:fs/promises";
|
|
178
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
179
|
+
import { execFileSync, execSync as execSync2 } from "node:child_process";
|
|
180
|
+
import { pbkdf2Sync, createDecipheriv } from "node:crypto";
|
|
181
|
+
import { homedir } from "node:os";
|
|
182
|
+
import { join as join3 } from "node:path";
|
|
183
|
+
|
|
184
|
+
// src/lib/leveldb-reader.ts
|
|
185
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
186
|
+
import { join as join2 } from "node:path";
|
|
187
|
+
import { snappyUncompress } from "hysnappy";
|
|
188
|
+
var LEVELDB_MAGIC = Buffer.from([87, 251, 128, 139, 36, 117, 71, 219]);
|
|
189
|
+
var COMPRESSION_NONE = 0;
|
|
190
|
+
var COMPRESSION_SNAPPY = 1;
|
|
191
|
+
var LOG_RECORD_FULL = 1;
|
|
192
|
+
var LOG_RECORD_FIRST = 2;
|
|
193
|
+
var LOG_RECORD_MIDDLE = 3;
|
|
194
|
+
var LOG_RECORD_LAST = 4;
|
|
195
|
+
function readVarint(buf, offset) {
|
|
196
|
+
let result = 0;
|
|
197
|
+
let shift = 0;
|
|
198
|
+
let bytesRead = 0;
|
|
199
|
+
while (offset + bytesRead < buf.length) {
|
|
200
|
+
const byte = buf[offset + bytesRead];
|
|
201
|
+
bytesRead++;
|
|
202
|
+
result |= (byte & 127) << shift;
|
|
203
|
+
if ((byte & 128) === 0) {
|
|
204
|
+
return [result, bytesRead];
|
|
205
|
+
}
|
|
206
|
+
shift += 7;
|
|
207
|
+
if (shift >= 35) {
|
|
208
|
+
throw new Error("Varint too long");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
throw new Error("Unexpected end of buffer reading varint");
|
|
212
|
+
}
|
|
213
|
+
function readVarint64(buf, offset) {
|
|
214
|
+
let result = 0;
|
|
215
|
+
let shift = 0;
|
|
216
|
+
let bytesRead = 0;
|
|
217
|
+
while (offset + bytesRead < buf.length && bytesRead < 10) {
|
|
218
|
+
const byte = buf[offset + bytesRead];
|
|
219
|
+
bytesRead++;
|
|
220
|
+
if (shift < 32) {
|
|
221
|
+
result |= (byte & 127) << shift;
|
|
222
|
+
}
|
|
223
|
+
if ((byte & 128) === 0) {
|
|
224
|
+
return [result >>> 0, bytesRead];
|
|
225
|
+
}
|
|
226
|
+
shift += 7;
|
|
227
|
+
}
|
|
228
|
+
throw new Error("Unexpected end of buffer reading varint64");
|
|
229
|
+
}
|
|
230
|
+
function getSnappyUncompressedLength(compressed) {
|
|
231
|
+
const [length] = readVarint(compressed, 0);
|
|
232
|
+
return length;
|
|
233
|
+
}
|
|
234
|
+
function parseBlockHandle(buf, offset) {
|
|
235
|
+
const [blockOffset, n1] = readVarint64(buf, offset);
|
|
236
|
+
const [blockSize, n2] = readVarint64(buf, offset + n1);
|
|
237
|
+
return { offset: blockOffset, size: blockSize, bytesRead: n1 + n2 };
|
|
238
|
+
}
|
|
239
|
+
function decompressBlock(blockData, compressionType) {
|
|
240
|
+
if (compressionType === COMPRESSION_NONE) {
|
|
241
|
+
return blockData;
|
|
242
|
+
}
|
|
243
|
+
if (compressionType === COMPRESSION_SNAPPY) {
|
|
244
|
+
const uncompressedLength = getSnappyUncompressedLength(blockData);
|
|
245
|
+
const result = snappyUncompress(blockData, uncompressedLength);
|
|
246
|
+
return Buffer.from(result);
|
|
247
|
+
}
|
|
248
|
+
throw new Error(`Unknown compression type: ${compressionType}`);
|
|
249
|
+
}
|
|
250
|
+
function parseDataBlock(block) {
|
|
251
|
+
const entries = [];
|
|
252
|
+
if (block.length < 4) {
|
|
253
|
+
return entries;
|
|
254
|
+
}
|
|
255
|
+
const numRestarts = block.readUInt32LE(block.length - 4);
|
|
256
|
+
const restartsStart = block.length - 4 - numRestarts * 4;
|
|
257
|
+
if (restartsStart < 0) {
|
|
258
|
+
return entries;
|
|
259
|
+
}
|
|
260
|
+
let offset = 0;
|
|
261
|
+
let prevKey = Buffer.alloc(0);
|
|
262
|
+
while (offset < restartsStart) {
|
|
263
|
+
try {
|
|
264
|
+
const [shared, n1] = readVarint(block, offset);
|
|
265
|
+
offset += n1;
|
|
266
|
+
const [nonShared, n2] = readVarint(block, offset);
|
|
267
|
+
offset += n2;
|
|
268
|
+
const [valueLen, n3] = readVarint(block, offset);
|
|
269
|
+
offset += n3;
|
|
270
|
+
if (offset + nonShared + valueLen > restartsStart) {
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
const keyDelta = block.subarray(offset, offset + nonShared);
|
|
274
|
+
offset += nonShared;
|
|
275
|
+
const key = Buffer.concat([prevKey.subarray(0, shared), keyDelta]);
|
|
276
|
+
const value = block.subarray(offset, offset + valueLen);
|
|
277
|
+
offset += valueLen;
|
|
278
|
+
entries.push({ key: Buffer.from(key), value: Buffer.from(value) });
|
|
279
|
+
prevKey = key;
|
|
280
|
+
} catch {
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return entries;
|
|
285
|
+
}
|
|
286
|
+
async function parseSSTable(filePath) {
|
|
287
|
+
const entries = [];
|
|
288
|
+
let data;
|
|
289
|
+
try {
|
|
290
|
+
data = await readFile(filePath);
|
|
291
|
+
} catch {
|
|
292
|
+
return entries;
|
|
293
|
+
}
|
|
294
|
+
if (data.length < 48) {
|
|
295
|
+
return entries;
|
|
296
|
+
}
|
|
297
|
+
const footer = data.subarray(-48);
|
|
298
|
+
const magic = footer.subarray(40, 48);
|
|
299
|
+
if (!magic.equals(LEVELDB_MAGIC)) {
|
|
300
|
+
return entries;
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
const { bytesRead: metaBytes } = parseBlockHandle(footer, 0);
|
|
304
|
+
const indexHandle = parseBlockHandle(footer, metaBytes);
|
|
305
|
+
const indexBlockStart = indexHandle.offset;
|
|
306
|
+
const indexBlockEnd = indexHandle.offset + indexHandle.size + 5;
|
|
307
|
+
if (indexBlockEnd > data.length - 48) {
|
|
308
|
+
return entries;
|
|
309
|
+
}
|
|
310
|
+
const indexBlockRaw = data.subarray(indexBlockStart, indexBlockEnd);
|
|
311
|
+
const indexCompressionType = indexBlockRaw.at(-5);
|
|
312
|
+
const indexBlockData = indexBlockRaw.subarray(0, -5);
|
|
313
|
+
const indexBlock = decompressBlock(indexBlockData, indexCompressionType);
|
|
314
|
+
const indexEntries = parseDataBlock(indexBlock);
|
|
315
|
+
for (const indexEntry of indexEntries) {
|
|
316
|
+
try {
|
|
317
|
+
const blockHandle = parseBlockHandle(indexEntry.value, 0);
|
|
318
|
+
const blockStart = blockHandle.offset;
|
|
319
|
+
const blockEnd = blockHandle.offset + blockHandle.size + 5;
|
|
320
|
+
if (blockEnd > data.length) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const blockRaw = data.subarray(blockStart, blockEnd);
|
|
324
|
+
const compressionType = blockRaw.at(-5);
|
|
325
|
+
const blockData = blockRaw.subarray(0, -5);
|
|
326
|
+
const block = decompressBlock(blockData, compressionType);
|
|
327
|
+
const blockEntries = parseDataBlock(block);
|
|
328
|
+
entries.push(...blockEntries);
|
|
329
|
+
} catch {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
return entries;
|
|
335
|
+
}
|
|
336
|
+
return entries;
|
|
337
|
+
}
|
|
338
|
+
async function parseLogFile(filePath) {
|
|
339
|
+
const entries = [];
|
|
340
|
+
let data;
|
|
341
|
+
try {
|
|
342
|
+
data = await readFile(filePath);
|
|
343
|
+
} catch {
|
|
344
|
+
return entries;
|
|
345
|
+
}
|
|
346
|
+
const BLOCK_SIZE = 32768;
|
|
347
|
+
let offset = 0;
|
|
348
|
+
let pendingRecord = [];
|
|
349
|
+
while (offset < data.length) {
|
|
350
|
+
const blockOffset = offset % BLOCK_SIZE;
|
|
351
|
+
const remaining = BLOCK_SIZE - blockOffset;
|
|
352
|
+
if (remaining < 7) {
|
|
353
|
+
offset += remaining;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (offset + 7 > data.length) {
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
const length = data.readUInt16LE(offset + 4);
|
|
360
|
+
const type = data[offset + 6];
|
|
361
|
+
if (length === 0 || offset + 7 + length > data.length) {
|
|
362
|
+
offset += remaining;
|
|
363
|
+
pendingRecord = [];
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const recordData = data.subarray(offset + 7, offset + 7 + length);
|
|
367
|
+
offset += 7 + length;
|
|
368
|
+
if (type === LOG_RECORD_FULL) {
|
|
369
|
+
pendingRecord = [];
|
|
370
|
+
parseLogBatch(recordData, entries);
|
|
371
|
+
} else if (type === LOG_RECORD_FIRST) {
|
|
372
|
+
pendingRecord = [recordData];
|
|
373
|
+
} else if (type === LOG_RECORD_MIDDLE) {
|
|
374
|
+
if (pendingRecord.length > 0) {
|
|
375
|
+
pendingRecord.push(recordData);
|
|
376
|
+
}
|
|
377
|
+
} else if (type === LOG_RECORD_LAST) {
|
|
378
|
+
if (pendingRecord.length > 0) {
|
|
379
|
+
pendingRecord.push(recordData);
|
|
380
|
+
const fullRecord = Buffer.concat(pendingRecord);
|
|
381
|
+
parseLogBatch(fullRecord, entries);
|
|
382
|
+
}
|
|
383
|
+
pendingRecord = [];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return entries;
|
|
387
|
+
}
|
|
388
|
+
function parseLogBatch(batch, entries) {
|
|
389
|
+
if (batch.length < 12) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
let offset = 12;
|
|
393
|
+
while (offset < batch.length) {
|
|
394
|
+
try {
|
|
395
|
+
const recordType = batch[offset];
|
|
396
|
+
offset++;
|
|
397
|
+
if (recordType === 1) {
|
|
398
|
+
const [keyLen, n1] = readVarint(batch, offset);
|
|
399
|
+
offset += n1;
|
|
400
|
+
const key = batch.subarray(offset, offset + keyLen);
|
|
401
|
+
offset += keyLen;
|
|
402
|
+
const [valueLen, n2] = readVarint(batch, offset);
|
|
403
|
+
offset += n2;
|
|
404
|
+
const value = batch.subarray(offset, offset + valueLen);
|
|
405
|
+
offset += valueLen;
|
|
406
|
+
entries.push({ key: Buffer.from(key), value: Buffer.from(value) });
|
|
407
|
+
} else if (recordType === 0) {
|
|
408
|
+
const [keyLen, n1] = readVarint(batch, offset);
|
|
409
|
+
offset += n1 + keyLen;
|
|
410
|
+
} else {
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
async function readChromiumLevelDB(dir) {
|
|
419
|
+
const entries = [];
|
|
420
|
+
let files;
|
|
421
|
+
try {
|
|
422
|
+
files = await readdir(dir);
|
|
423
|
+
} catch {
|
|
424
|
+
return entries;
|
|
425
|
+
}
|
|
426
|
+
const sstFiles = files.filter((f) => f.endsWith(".ldb") || f.endsWith(".sst"));
|
|
427
|
+
for (const file of sstFiles) {
|
|
428
|
+
const fileEntries = await parseSSTable(join2(dir, file));
|
|
429
|
+
entries.push(...fileEntries);
|
|
430
|
+
}
|
|
431
|
+
const logFiles = files.filter((f) => f.endsWith(".log"));
|
|
432
|
+
for (const file of logFiles) {
|
|
433
|
+
const fileEntries = await parseLogFile(join2(dir, file));
|
|
434
|
+
entries.push(...fileEntries);
|
|
435
|
+
}
|
|
436
|
+
return entries;
|
|
437
|
+
}
|
|
438
|
+
async function findKeysContaining(dir, substring) {
|
|
439
|
+
const allEntries = await readChromiumLevelDB(dir);
|
|
440
|
+
return allEntries.filter((entry) => entry.key.includes(substring));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// src/auth/desktop.ts
|
|
444
|
+
async function queryReadonlySqlite(dbPath, sql) {
|
|
445
|
+
try {
|
|
446
|
+
const { Database } = await import("bun:sqlite");
|
|
447
|
+
const db = new Database(dbPath, { readonly: true });
|
|
448
|
+
try {
|
|
449
|
+
return db.query(sql).all();
|
|
450
|
+
} finally {
|
|
451
|
+
db.close();
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
455
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
456
|
+
try {
|
|
457
|
+
return db.prepare(sql).all();
|
|
458
|
+
} finally {
|
|
459
|
+
db.close();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
var SLACK_SUPPORT_DIR_ELECTRON = join3(homedir(), "Library", "Application Support", "Slack");
|
|
464
|
+
var SLACK_SUPPORT_DIR_APPSTORE = join3(homedir(), "Library", "Containers", "com.tinyspeck.slackmacgap", "Data", "Library", "Application Support", "Slack");
|
|
465
|
+
function getSlackPaths() {
|
|
466
|
+
const candidates = [SLACK_SUPPORT_DIR_ELECTRON, SLACK_SUPPORT_DIR_APPSTORE];
|
|
467
|
+
for (const dir of candidates) {
|
|
468
|
+
const leveldbDir = join3(dir, "Local Storage", "leveldb");
|
|
469
|
+
if (existsSync2(leveldbDir)) {
|
|
470
|
+
return { leveldbDir, cookiesDb: join3(dir, "Cookies") };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
throw new Error(`Slack Desktop data not found. Checked:
|
|
474
|
+
- ${candidates.map((d) => join3(d, "Local Storage", "leveldb")).join(`
|
|
475
|
+
- `)}`);
|
|
476
|
+
}
|
|
477
|
+
function isRecord2(value) {
|
|
478
|
+
return typeof value === "object" && value !== null;
|
|
479
|
+
}
|
|
480
|
+
function toDesktopTeam(value) {
|
|
481
|
+
if (!isRecord2(value)) {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
const url = typeof value.url === "string" ? value.url : null;
|
|
485
|
+
const token = typeof value.token === "string" ? value.token : null;
|
|
486
|
+
if (!url || !token) {
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
const name = typeof value.name === "string" ? value.name : undefined;
|
|
490
|
+
return { url, name, token };
|
|
491
|
+
}
|
|
492
|
+
async function snapshotLevelDb(srcDir) {
|
|
493
|
+
const base = join3(homedir(), ".config", "agent-slack", "cache", "leveldb-snapshots");
|
|
494
|
+
const dest = join3(base, `${Date.now()}`);
|
|
495
|
+
await mkdir(base, { recursive: true });
|
|
496
|
+
try {
|
|
497
|
+
execFileSync("cp", ["-cR", srcDir, dest], {
|
|
498
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
499
|
+
});
|
|
500
|
+
} catch {
|
|
501
|
+
await cp(srcDir, dest, { recursive: true, force: true });
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
await unlink(join3(dest, "LOCK"));
|
|
505
|
+
} catch {}
|
|
506
|
+
return dest;
|
|
507
|
+
}
|
|
508
|
+
function parseLocalConfig(raw) {
|
|
509
|
+
if (!raw || raw.length === 0) {
|
|
510
|
+
throw new Error("localConfig is empty");
|
|
511
|
+
}
|
|
512
|
+
const [first] = raw;
|
|
513
|
+
const data = first === 0 || first === 1 || first === 2 ? raw.subarray(1) : raw;
|
|
514
|
+
let nulCount = 0;
|
|
515
|
+
for (const b of data) {
|
|
516
|
+
if (b === 0) {
|
|
517
|
+
nulCount++;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const encodings = nulCount > data.length / 4 ? ["utf16le", "utf8"] : ["utf8", "utf16le"];
|
|
521
|
+
let lastErr;
|
|
522
|
+
for (const enc of encodings) {
|
|
523
|
+
try {
|
|
524
|
+
const text = data.toString(enc);
|
|
525
|
+
try {
|
|
526
|
+
return JSON.parse(text);
|
|
527
|
+
} catch (err1) {
|
|
528
|
+
lastErr = err1;
|
|
529
|
+
}
|
|
530
|
+
const start = text.indexOf("{");
|
|
531
|
+
const end = text.lastIndexOf("}");
|
|
532
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
533
|
+
try {
|
|
534
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
535
|
+
} catch (err2) {
|
|
536
|
+
lastErr = err2;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
} catch (err) {
|
|
540
|
+
lastErr = err;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
throw lastErr || new Error("localConfig not parseable");
|
|
544
|
+
}
|
|
545
|
+
async function extractTeamsFromSlackLevelDb(leveldbDir) {
|
|
546
|
+
if (!existsSync2(leveldbDir)) {
|
|
547
|
+
throw new Error(`Slack LevelDB not found: ${leveldbDir}`);
|
|
548
|
+
}
|
|
549
|
+
const snap = await snapshotLevelDb(leveldbDir);
|
|
550
|
+
try {
|
|
551
|
+
const localConfigV2 = Buffer.from("localConfig_v2");
|
|
552
|
+
const localConfigV3 = Buffer.from("localConfig_v3");
|
|
553
|
+
const entries = await findKeysContaining(snap, Buffer.from("localConfig_v"));
|
|
554
|
+
let configBuf = null;
|
|
555
|
+
for (const entry of entries) {
|
|
556
|
+
if (entry.key.includes(localConfigV2) || entry.key.includes(localConfigV3)) {
|
|
557
|
+
if (entry.value && entry.value.length > 0) {
|
|
558
|
+
configBuf = entry.value;
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (!configBuf) {
|
|
564
|
+
throw new Error("Slack LevelDB did not contain localConfig_v2/v3");
|
|
565
|
+
}
|
|
566
|
+
const cfg = parseLocalConfig(configBuf);
|
|
567
|
+
const teamsValue = isRecord2(cfg) ? cfg.teams : undefined;
|
|
568
|
+
const teamsObj = isRecord2(teamsValue) ? teamsValue : {};
|
|
569
|
+
const teams = Object.values(teamsObj).map((t) => toDesktopTeam(t)).filter((t) => t !== null).filter((t) => t.token.startsWith("xoxc-"));
|
|
570
|
+
if (teams.length === 0) {
|
|
571
|
+
throw new Error("No xoxc tokens found in Slack localConfig");
|
|
572
|
+
}
|
|
573
|
+
return teams;
|
|
574
|
+
} finally {
|
|
575
|
+
try {
|
|
576
|
+
await rm(snap, { recursive: true, force: true });
|
|
577
|
+
} catch {}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function getSafeStoragePassword() {
|
|
581
|
+
const services = ["Slack Safe Storage", "Chrome Safe Storage", "Chromium Safe Storage"];
|
|
582
|
+
for (const svc of services) {
|
|
583
|
+
try {
|
|
584
|
+
const out = execSync2(`security find-generic-password -w -s ${JSON.stringify(svc)} 2>/dev/null`, {
|
|
585
|
+
encoding: "utf8",
|
|
586
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
587
|
+
}).trim();
|
|
588
|
+
if (out) {
|
|
589
|
+
return out;
|
|
590
|
+
}
|
|
591
|
+
} catch {}
|
|
592
|
+
}
|
|
593
|
+
throw new Error('Could not read Safe Storage password from Keychain (tried "Slack Safe Storage").');
|
|
594
|
+
}
|
|
595
|
+
function decryptChromiumCookieValue(encrypted, password) {
|
|
596
|
+
if (!encrypted || encrypted.length === 0) {
|
|
597
|
+
return "";
|
|
598
|
+
}
|
|
599
|
+
const prefix = encrypted.subarray(0, 3).toString("utf8");
|
|
600
|
+
const data = prefix === "v10" || prefix === "v11" ? encrypted.subarray(3) : encrypted;
|
|
601
|
+
const salt = Buffer.from("saltysalt", "utf8");
|
|
602
|
+
const iv = Buffer.alloc(16, " ");
|
|
603
|
+
const key = pbkdf2Sync(password, salt, 1003, 16, "sha1");
|
|
604
|
+
const decipher = createDecipheriv("aes-128-cbc", key, iv);
|
|
605
|
+
decipher.setAutoPadding(true);
|
|
606
|
+
const plain = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
607
|
+
const marker = Buffer.from("xoxd-");
|
|
608
|
+
const idx = plain.indexOf(marker);
|
|
609
|
+
if (idx === -1) {
|
|
610
|
+
return plain.toString("utf8");
|
|
611
|
+
}
|
|
612
|
+
let end = idx;
|
|
613
|
+
while (end < plain.length) {
|
|
614
|
+
const b = plain[end];
|
|
615
|
+
if (b < 33 || b > 126) {
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
end++;
|
|
619
|
+
}
|
|
620
|
+
const rawToken = plain.subarray(idx, end).toString("utf8");
|
|
621
|
+
try {
|
|
622
|
+
return decodeURIComponent(rawToken);
|
|
623
|
+
} catch {
|
|
624
|
+
return rawToken;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async function extractCookieDFromSlackCookiesDb(cookiesPath) {
|
|
628
|
+
if (!existsSync2(cookiesPath)) {
|
|
629
|
+
throw new Error(`Slack Cookies DB not found: ${cookiesPath}`);
|
|
630
|
+
}
|
|
631
|
+
const rows = await queryReadonlySqlite(cookiesPath, "select host_key, name, value, encrypted_value from cookies where name = 'd' and host_key like '%slack.com' order by length(encrypted_value) desc");
|
|
632
|
+
if (!rows || rows.length === 0) {
|
|
633
|
+
throw new Error("No Slack 'd' cookie found");
|
|
634
|
+
}
|
|
635
|
+
const row = rows[0];
|
|
636
|
+
if (row.value && row.value.startsWith("xoxd-")) {
|
|
637
|
+
return row.value;
|
|
638
|
+
}
|
|
639
|
+
const encrypted = Buffer.from(row.encrypted_value || []);
|
|
640
|
+
if (encrypted.length === 0) {
|
|
641
|
+
throw new Error("Slack 'd' cookie had no encrypted_value");
|
|
642
|
+
}
|
|
643
|
+
const password = getSafeStoragePassword();
|
|
644
|
+
const decrypted = decryptChromiumCookieValue(encrypted, password);
|
|
645
|
+
const match = decrypted.match(/xoxd-[A-Za-z0-9%/+_=.-]+/);
|
|
646
|
+
if (!match) {
|
|
647
|
+
throw new Error("Could not locate xoxd-* in decrypted Slack cookie");
|
|
648
|
+
}
|
|
649
|
+
return match[0];
|
|
650
|
+
}
|
|
651
|
+
async function extractFromSlackDesktop() {
|
|
652
|
+
const { leveldbDir, cookiesDb } = getSlackPaths();
|
|
653
|
+
const teams = await extractTeamsFromSlackLevelDb(leveldbDir);
|
|
654
|
+
const cookie_d = await extractCookieDFromSlackCookiesDb(cookiesDb);
|
|
655
|
+
return {
|
|
656
|
+
cookie_d,
|
|
657
|
+
teams,
|
|
658
|
+
source: { leveldb_path: leveldbDir, cookies_path: cookiesDb }
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// src/auth/paths.ts
|
|
663
|
+
import { homedir as homedir2 } from "node:os";
|
|
664
|
+
import { join as join4 } from "node:path";
|
|
665
|
+
var AGENT_SLACK_DIR = join4(homedir2(), ".config", "agent-slack");
|
|
666
|
+
var CREDENTIALS_FILE = join4(AGENT_SLACK_DIR, "credentials.json");
|
|
667
|
+
var KEYCHAIN_SERVICE = "agent-slack";
|
|
668
|
+
|
|
669
|
+
// src/lib/fs.ts
|
|
670
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile } from "node:fs/promises";
|
|
671
|
+
import { dirname as dirname2 } from "node:path";
|
|
672
|
+
async function readJsonFile(path) {
|
|
673
|
+
try {
|
|
674
|
+
const raw = await readFile2(path, "utf8");
|
|
675
|
+
return JSON.parse(raw);
|
|
676
|
+
} catch (err) {
|
|
677
|
+
if (isRecord3(err) && err.code === "ENOENT") {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
if (err instanceof SyntaxError) {
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
throw err;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
async function writeJsonFile(path, data) {
|
|
687
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
688
|
+
await writeFile(path, `${JSON.stringify(data, null, 2)}
|
|
689
|
+
`, { mode: 384 });
|
|
690
|
+
}
|
|
691
|
+
function isRecord3(value) {
|
|
692
|
+
return typeof value === "object" && value !== null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/auth/schema.ts
|
|
696
|
+
import { z } from "zod";
|
|
697
|
+
var WorkspaceAuthSchema = z.union([
|
|
698
|
+
z.object({
|
|
699
|
+
auth_type: z.literal("standard"),
|
|
700
|
+
token: z.string().min(1)
|
|
701
|
+
}),
|
|
702
|
+
z.object({
|
|
703
|
+
auth_type: z.literal("browser"),
|
|
704
|
+
xoxc_token: z.string().min(1),
|
|
705
|
+
xoxd_cookie: z.string().min(1)
|
|
706
|
+
})
|
|
707
|
+
]);
|
|
708
|
+
var WorkspaceSchema = z.object({
|
|
709
|
+
workspace_url: z.string().url(),
|
|
710
|
+
workspace_name: z.string().optional(),
|
|
711
|
+
team_id: z.string().optional(),
|
|
712
|
+
team_domain: z.string().optional(),
|
|
713
|
+
auth: WorkspaceAuthSchema
|
|
714
|
+
});
|
|
715
|
+
var CredentialsSchema = z.object({
|
|
716
|
+
version: z.literal(1),
|
|
717
|
+
updated_at: z.string().optional(),
|
|
718
|
+
default_workspace_url: z.string().url().optional(),
|
|
719
|
+
workspaces: z.array(WorkspaceSchema).default([])
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
// src/auth/keychain.ts
|
|
723
|
+
import { platform as platform2 } from "node:os";
|
|
724
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
725
|
+
var IS_MACOS2 = platform2() === "darwin";
|
|
726
|
+
function keychainGet(account, service) {
|
|
727
|
+
if (!IS_MACOS2) {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
try {
|
|
731
|
+
const result = execFileSync2("security", ["find-generic-password", "-s", service, "-a", account, "-w"], { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] });
|
|
732
|
+
return result.trim() || null;
|
|
733
|
+
} catch {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function keychainSet(input) {
|
|
738
|
+
if (!IS_MACOS2) {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
const { account, value, service } = input;
|
|
742
|
+
try {
|
|
743
|
+
try {
|
|
744
|
+
execFileSync2("security", ["delete-generic-password", "-s", service, "-a", account], {
|
|
745
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
746
|
+
});
|
|
747
|
+
} catch {}
|
|
748
|
+
execFileSync2("security", ["add-generic-password", "-s", service, "-a", account, "-w", value], {
|
|
749
|
+
stdio: "pipe"
|
|
750
|
+
});
|
|
751
|
+
return true;
|
|
752
|
+
} catch {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// src/auth/store.ts
|
|
758
|
+
import { platform as platform3 } from "node:os";
|
|
759
|
+
var KEYCHAIN_PLACEHOLDER = "__KEYCHAIN__";
|
|
760
|
+
var IS_MACOS3 = platform3() === "darwin";
|
|
761
|
+
function normalizeWorkspaceUrl(workspaceUrl) {
|
|
762
|
+
const u = new URL(workspaceUrl);
|
|
763
|
+
return `${u.protocol}//${u.host}`;
|
|
764
|
+
}
|
|
765
|
+
function isPlaceholderSecret(value) {
|
|
766
|
+
return !value || value === KEYCHAIN_PLACEHOLDER;
|
|
767
|
+
}
|
|
768
|
+
async function loadCredentials() {
|
|
769
|
+
const fromFile = await readJsonFile(CREDENTIALS_FILE);
|
|
770
|
+
const parsed = CredentialsSchema.safeParse(fromFile ?? { version: 1, workspaces: [] });
|
|
771
|
+
if (!parsed.success) {
|
|
772
|
+
return { version: 1, workspaces: [] };
|
|
773
|
+
}
|
|
774
|
+
const creds = parsed.data;
|
|
775
|
+
const hydrated = creds.workspaces.map((w) => {
|
|
776
|
+
if (w.auth.auth_type === "browser") {
|
|
777
|
+
const account = `xoxc:${normalizeWorkspaceUrl(w.workspace_url)}`;
|
|
778
|
+
const xoxc = keychainGet(account, KEYCHAIN_SERVICE);
|
|
779
|
+
const xoxd = keychainGet("xoxd", KEYCHAIN_SERVICE);
|
|
780
|
+
return {
|
|
781
|
+
...w,
|
|
782
|
+
auth: {
|
|
783
|
+
auth_type: "browser",
|
|
784
|
+
xoxc_token: xoxc ?? w.auth.xoxc_token,
|
|
785
|
+
xoxd_cookie: xoxd ?? w.auth.xoxd_cookie
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
if (w.auth.auth_type === "standard") {
|
|
790
|
+
const account = `token:${normalizeWorkspaceUrl(w.workspace_url)}`;
|
|
791
|
+
const token = keychainGet(account, KEYCHAIN_SERVICE);
|
|
792
|
+
return {
|
|
793
|
+
...w,
|
|
794
|
+
auth: {
|
|
795
|
+
auth_type: "standard",
|
|
796
|
+
token: token ?? w.auth.token
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
return w;
|
|
801
|
+
});
|
|
802
|
+
return { ...creds, workspaces: hydrated };
|
|
803
|
+
}
|
|
804
|
+
async function saveCredentials(credentials) {
|
|
805
|
+
const payload = {
|
|
806
|
+
...credentials,
|
|
807
|
+
updated_at: new Date().toISOString(),
|
|
808
|
+
workspaces: credentials.workspaces.map((w) => ({
|
|
809
|
+
...w,
|
|
810
|
+
workspace_url: normalizeWorkspaceUrl(w.workspace_url)
|
|
811
|
+
}))
|
|
812
|
+
};
|
|
813
|
+
const filePayload = structuredClone(payload);
|
|
814
|
+
if (IS_MACOS3) {
|
|
815
|
+
const firstBrowser = payload.workspaces.find((w) => w.auth.auth_type === "browser");
|
|
816
|
+
let xoxdStored = false;
|
|
817
|
+
if (firstBrowser?.auth.auth_type === "browser" && !isPlaceholderSecret(firstBrowser.auth.xoxd_cookie)) {
|
|
818
|
+
const existing = keychainGet("xoxd", KEYCHAIN_SERVICE);
|
|
819
|
+
xoxdStored = existing === firstBrowser.auth.xoxd_cookie || keychainSet({
|
|
820
|
+
account: "xoxd",
|
|
821
|
+
value: firstBrowser.auth.xoxd_cookie,
|
|
822
|
+
service: KEYCHAIN_SERVICE
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
for (const w of filePayload.workspaces) {
|
|
826
|
+
if (w.auth.auth_type === "browser") {
|
|
827
|
+
const account = `xoxc:${normalizeWorkspaceUrl(w.workspace_url)}`;
|
|
828
|
+
const tokenStored = isPlaceholderSecret(w.auth.xoxc_token) || keychainGet(account, KEYCHAIN_SERVICE) === w.auth.xoxc_token || keychainSet({ account, value: w.auth.xoxc_token, service: KEYCHAIN_SERVICE });
|
|
829
|
+
if (tokenStored) {
|
|
830
|
+
w.auth.xoxc_token = KEYCHAIN_PLACEHOLDER;
|
|
831
|
+
}
|
|
832
|
+
if (xoxdStored) {
|
|
833
|
+
w.auth.xoxd_cookie = KEYCHAIN_PLACEHOLDER;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (w.auth.auth_type === "standard") {
|
|
837
|
+
const account = `token:${normalizeWorkspaceUrl(w.workspace_url)}`;
|
|
838
|
+
const tokenStored = isPlaceholderSecret(w.auth.token) || keychainGet(account, KEYCHAIN_SERVICE) === w.auth.token || keychainSet({ account, value: w.auth.token, service: KEYCHAIN_SERVICE });
|
|
839
|
+
if (tokenStored) {
|
|
840
|
+
w.auth.token = KEYCHAIN_PLACEHOLDER;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
await writeJsonFile(CREDENTIALS_FILE, filePayload);
|
|
846
|
+
}
|
|
847
|
+
async function upsertWorkspace(workspace) {
|
|
848
|
+
const creds = await loadCredentials();
|
|
849
|
+
const normalizedUrl = normalizeWorkspaceUrl(workspace.workspace_url);
|
|
850
|
+
const next = { ...workspace, workspace_url: normalizedUrl };
|
|
851
|
+
const idx = creds.workspaces.findIndex((w) => normalizeWorkspaceUrl(w.workspace_url) === normalizedUrl);
|
|
852
|
+
if (idx === -1) {
|
|
853
|
+
creds.workspaces.push(next);
|
|
854
|
+
} else {
|
|
855
|
+
creds.workspaces[idx] = {
|
|
856
|
+
...creds.workspaces[idx],
|
|
857
|
+
...next,
|
|
858
|
+
auth: next.auth
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
if (!creds.default_workspace_url) {
|
|
862
|
+
creds.default_workspace_url = normalizedUrl;
|
|
863
|
+
}
|
|
864
|
+
await saveCredentials(creds);
|
|
865
|
+
return next;
|
|
866
|
+
}
|
|
867
|
+
async function upsertWorkspaces(workspaces) {
|
|
868
|
+
if (workspaces.length === 0) {
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const creds = await loadCredentials();
|
|
872
|
+
for (const workspace of workspaces) {
|
|
873
|
+
const normalizedUrl = normalizeWorkspaceUrl(workspace.workspace_url);
|
|
874
|
+
const next = { ...workspace, workspace_url: normalizedUrl };
|
|
875
|
+
const idx = creds.workspaces.findIndex((w) => normalizeWorkspaceUrl(w.workspace_url) === normalizedUrl);
|
|
876
|
+
if (idx === -1) {
|
|
877
|
+
creds.workspaces.push(next);
|
|
878
|
+
} else {
|
|
879
|
+
creds.workspaces[idx] = {
|
|
880
|
+
...creds.workspaces[idx],
|
|
881
|
+
...next,
|
|
882
|
+
auth: next.auth
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
if (!creds.default_workspace_url) {
|
|
886
|
+
creds.default_workspace_url = normalizedUrl;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
await saveCredentials(creds);
|
|
890
|
+
}
|
|
891
|
+
async function setDefaultWorkspace(workspaceUrl) {
|
|
892
|
+
const creds = await loadCredentials();
|
|
893
|
+
creds.default_workspace_url = normalizeWorkspaceUrl(workspaceUrl);
|
|
894
|
+
await saveCredentials(creds);
|
|
895
|
+
}
|
|
896
|
+
async function removeWorkspace(workspaceUrl) {
|
|
897
|
+
const creds = await loadCredentials();
|
|
898
|
+
const normalized = normalizeWorkspaceUrl(workspaceUrl);
|
|
899
|
+
creds.workspaces = creds.workspaces.filter((w) => normalizeWorkspaceUrl(w.workspace_url) !== normalized);
|
|
900
|
+
if (creds.default_workspace_url === normalized) {
|
|
901
|
+
creds.default_workspace_url = creds.workspaces[0]?.workspace_url;
|
|
902
|
+
}
|
|
903
|
+
await saveCredentials(creds);
|
|
904
|
+
}
|
|
905
|
+
async function resolveWorkspaceForUrl(workspaceUrl) {
|
|
906
|
+
const creds = await loadCredentials();
|
|
907
|
+
const normalized = normalizeWorkspaceUrl(workspaceUrl);
|
|
908
|
+
return creds.workspaces.find((w) => normalizeWorkspaceUrl(w.workspace_url) === normalized) ?? null;
|
|
909
|
+
}
|
|
910
|
+
async function resolveDefaultWorkspace() {
|
|
911
|
+
const creds = await loadCredentials();
|
|
912
|
+
if (creds.default_workspace_url) {
|
|
913
|
+
const byDefault = creds.workspaces.find((w) => w.workspace_url === creds.default_workspace_url);
|
|
914
|
+
if (byDefault) {
|
|
915
|
+
return byDefault;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return creds.workspaces[0] ?? null;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// src/slack/channels.ts
|
|
922
|
+
function isChannelId(input) {
|
|
923
|
+
return /^[CDG][A-Z0-9]{8,}$/.test(input);
|
|
924
|
+
}
|
|
925
|
+
function normalizeChannelInput(input) {
|
|
926
|
+
const trimmed = input.trim();
|
|
927
|
+
if (trimmed.startsWith("#")) {
|
|
928
|
+
return { kind: "name", value: trimmed.slice(1) };
|
|
929
|
+
}
|
|
930
|
+
if (isChannelId(trimmed)) {
|
|
931
|
+
return { kind: "id", value: trimmed };
|
|
932
|
+
}
|
|
933
|
+
return { kind: "name", value: trimmed };
|
|
934
|
+
}
|
|
935
|
+
async function resolveChannelId(client, input) {
|
|
936
|
+
const normalized = normalizeChannelInput(input);
|
|
937
|
+
if (normalized.kind === "id") {
|
|
938
|
+
return normalized.value;
|
|
939
|
+
}
|
|
940
|
+
const name = normalized.value;
|
|
941
|
+
if (!name) {
|
|
942
|
+
throw new Error("Channel name is empty");
|
|
943
|
+
}
|
|
944
|
+
let cursor;
|
|
945
|
+
const matches = [];
|
|
946
|
+
for (;; ) {
|
|
947
|
+
const resp = await client.api("conversations.list", {
|
|
948
|
+
exclude_archived: true,
|
|
949
|
+
limit: 200,
|
|
950
|
+
cursor,
|
|
951
|
+
types: "public_channel,private_channel"
|
|
952
|
+
});
|
|
953
|
+
const chans = asArray(resp.channels).filter(isRecord4);
|
|
954
|
+
for (const c of chans) {
|
|
955
|
+
if (getString(c.name) === name && getString(c.id)) {
|
|
956
|
+
matches.push({
|
|
957
|
+
id: getString(c.id) ?? "",
|
|
958
|
+
name: getString(c.name) ?? undefined,
|
|
959
|
+
is_private: typeof c.is_private === "boolean" ? c.is_private : undefined
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
const meta = isRecord4(resp.response_metadata) ? resp.response_metadata : null;
|
|
964
|
+
const next = meta ? getString(meta.next_cursor) : undefined;
|
|
965
|
+
if (!next) {
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
968
|
+
cursor = next;
|
|
969
|
+
}
|
|
970
|
+
if (matches.length === 1) {
|
|
971
|
+
return matches[0].id;
|
|
972
|
+
}
|
|
973
|
+
if (matches.length === 0) {
|
|
974
|
+
throw new Error(`Could not resolve channel name: #${name}`);
|
|
975
|
+
}
|
|
976
|
+
throw new Error(`Ambiguous channel name: #${name} (matched ${matches.length} channels: ${matches.map((m) => m.id).join(", ")})`);
|
|
977
|
+
}
|
|
978
|
+
function isRecord4(value) {
|
|
979
|
+
return typeof value === "object" && value !== null;
|
|
980
|
+
}
|
|
981
|
+
function asArray(value) {
|
|
982
|
+
return Array.isArray(value) ? value : [];
|
|
983
|
+
}
|
|
984
|
+
function getString(value) {
|
|
985
|
+
return typeof value === "string" ? value : undefined;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// src/slack/client.ts
|
|
989
|
+
import { WebClient } from "@slack/web-api";
|
|
990
|
+
class SlackApiClient {
|
|
991
|
+
auth;
|
|
992
|
+
web;
|
|
993
|
+
workspaceUrl;
|
|
994
|
+
constructor(auth, options) {
|
|
995
|
+
this.auth = auth;
|
|
996
|
+
this.workspaceUrl = options?.workspaceUrl;
|
|
997
|
+
if (auth.auth_type === "standard") {
|
|
998
|
+
this.web = new WebClient(auth.token);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
async api(method, params = {}) {
|
|
1002
|
+
if (this.auth.auth_type === "standard") {
|
|
1003
|
+
if (!this.web) {
|
|
1004
|
+
throw new Error("WebClient not initialized");
|
|
1005
|
+
}
|
|
1006
|
+
return await this.web.apiCall(method, params);
|
|
1007
|
+
}
|
|
1008
|
+
if (!this.workspaceUrl) {
|
|
1009
|
+
throw new Error("Browser auth requires workspace URL. Provide --workspace-url or set SLACK_WORKSPACE_URL, or call via a Slack message URL.");
|
|
1010
|
+
}
|
|
1011
|
+
const { auth } = this;
|
|
1012
|
+
if (auth.auth_type !== "browser") {
|
|
1013
|
+
throw new Error("Browser API requires browser auth");
|
|
1014
|
+
}
|
|
1015
|
+
return this.browserApi({
|
|
1016
|
+
workspaceUrl: this.workspaceUrl,
|
|
1017
|
+
auth,
|
|
1018
|
+
method,
|
|
1019
|
+
params
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
async browserApi(input) {
|
|
1023
|
+
const attempt = input.attempt ?? 0;
|
|
1024
|
+
const url = `${input.workspaceUrl.replace(/\/$/, "")}/api/${input.method}`;
|
|
1025
|
+
const cleanedEntries = Object.entries(input.params).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)]);
|
|
1026
|
+
const formBody = new URLSearchParams({
|
|
1027
|
+
token: input.auth.xoxc_token,
|
|
1028
|
+
...Object.fromEntries(cleanedEntries)
|
|
1029
|
+
});
|
|
1030
|
+
const response = await fetch(url, {
|
|
1031
|
+
method: "POST",
|
|
1032
|
+
headers: {
|
|
1033
|
+
Cookie: `d=${encodeURIComponent(input.auth.xoxd_cookie)}`,
|
|
1034
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1035
|
+
Origin: "https://app.slack.com",
|
|
1036
|
+
"User-Agent": getUserAgent()
|
|
1037
|
+
},
|
|
1038
|
+
body: formBody
|
|
1039
|
+
});
|
|
1040
|
+
if (response.status === 429 && attempt < 3) {
|
|
1041
|
+
const retryAfter = Number(response.headers.get("Retry-After") ?? "5");
|
|
1042
|
+
const delayMs = Math.min(Math.max(retryAfter, 1) * 1000, 30000);
|
|
1043
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
1044
|
+
return this.browserApi({
|
|
1045
|
+
...input,
|
|
1046
|
+
attempt: attempt + 1
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
const data = await response.json().catch(() => ({}));
|
|
1050
|
+
if (!response.ok) {
|
|
1051
|
+
throw new Error(`Slack HTTP ${response.status} calling ${input.method}`);
|
|
1052
|
+
}
|
|
1053
|
+
if (!isRecord5(data) || data.ok !== true) {
|
|
1054
|
+
const error = isRecord5(data) && typeof data.error === "string" ? data.error : null;
|
|
1055
|
+
throw new Error(error || `Slack API error calling ${input.method}`);
|
|
1056
|
+
}
|
|
1057
|
+
return data;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
function isRecord5(value) {
|
|
1061
|
+
return typeof value === "object" && value !== null;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// src/cli/context.ts
|
|
1065
|
+
function isEnvAuthConfigured() {
|
|
1066
|
+
return Boolean(process.env.SLACK_TOKEN?.trim());
|
|
1067
|
+
}
|
|
1068
|
+
function effectiveWorkspaceUrl(flag) {
|
|
1069
|
+
return flag?.trim() || process.env.SLACK_WORKSPACE_URL?.trim() || undefined;
|
|
1070
|
+
}
|
|
1071
|
+
function errorMessage(err) {
|
|
1072
|
+
return err instanceof Error ? err.message : String(err);
|
|
1073
|
+
}
|
|
1074
|
+
function parseContentType(value) {
|
|
1075
|
+
const raw = String(value ?? "any").toLowerCase();
|
|
1076
|
+
if (raw === "text" || raw === "image" || raw === "snippet" || raw === "file") {
|
|
1077
|
+
return raw;
|
|
1078
|
+
}
|
|
1079
|
+
return "any";
|
|
1080
|
+
}
|
|
1081
|
+
async function assertWorkspaceSpecifiedForChannelNames(input) {
|
|
1082
|
+
const hasName = input.channels.some((c) => normalizeChannelInput(c).kind === "name");
|
|
1083
|
+
if (!hasName) {
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const creds = await loadCredentials();
|
|
1087
|
+
if ((creds.workspaces?.length ?? 0) <= 1) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
if (!input.workspaceUrl) {
|
|
1091
|
+
throw new Error('Ambiguous channel name across multiple workspaces. Pass --workspace "https://...slack.com" (or set SLACK_WORKSPACE_URL).');
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
function isAuthErrorMessage(message) {
|
|
1095
|
+
return /(?:^|[^a-z])(invalid_auth|token_expired)(?:$|[^a-z])/i.test(message);
|
|
1096
|
+
}
|
|
1097
|
+
function normalizeUrl(u) {
|
|
1098
|
+
const url = new URL(u);
|
|
1099
|
+
return `${url.protocol}//${url.host}`;
|
|
1100
|
+
}
|
|
1101
|
+
async function refreshFromDesktopIfPossible() {
|
|
1102
|
+
if (process.platform !== "darwin") {
|
|
1103
|
+
return false;
|
|
1104
|
+
}
|
|
1105
|
+
try {
|
|
1106
|
+
const extracted = await extractFromSlackDesktop();
|
|
1107
|
+
await upsertWorkspaces(extracted.teams.map((team) => ({
|
|
1108
|
+
workspace_url: normalizeUrl(team.url),
|
|
1109
|
+
workspace_name: team.name,
|
|
1110
|
+
auth: {
|
|
1111
|
+
auth_type: "browser",
|
|
1112
|
+
xoxc_token: team.token,
|
|
1113
|
+
xoxd_cookie: extracted.cookie_d
|
|
1114
|
+
}
|
|
1115
|
+
})));
|
|
1116
|
+
return true;
|
|
1117
|
+
} catch {
|
|
1118
|
+
return false;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
async function withAutoRefresh(input) {
|
|
1122
|
+
try {
|
|
1123
|
+
return await input.work();
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
const message = errorMessage(err);
|
|
1126
|
+
if (isEnvAuthConfigured()) {
|
|
1127
|
+
throw err;
|
|
1128
|
+
}
|
|
1129
|
+
if (!isAuthErrorMessage(message)) {
|
|
1130
|
+
throw err;
|
|
1131
|
+
}
|
|
1132
|
+
const refreshed = await refreshFromDesktopIfPossible();
|
|
1133
|
+
if (!refreshed) {
|
|
1134
|
+
throw err;
|
|
1135
|
+
}
|
|
1136
|
+
return await input.work();
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
function pickAuthFromEnv() {
|
|
1140
|
+
const token = process.env.SLACK_TOKEN?.trim();
|
|
1141
|
+
if (!token) {
|
|
1142
|
+
return null;
|
|
1143
|
+
}
|
|
1144
|
+
if (token.startsWith("xoxc-")) {
|
|
1145
|
+
const cookie = (process.env.SLACK_COOKIE_D || process.env.SLACK_COOKIE || "").trim();
|
|
1146
|
+
if (!cookie) {
|
|
1147
|
+
throw new Error("SLACK_TOKEN looks like xoxc- but SLACK_COOKIE_D is missing");
|
|
1148
|
+
}
|
|
1149
|
+
return { auth_type: "browser", xoxc_token: token, xoxd_cookie: cookie };
|
|
1150
|
+
}
|
|
1151
|
+
return { auth_type: "standard", token };
|
|
1152
|
+
}
|
|
1153
|
+
async function getClientForWorkspace(workspaceUrl) {
|
|
1154
|
+
const env = pickAuthFromEnv();
|
|
1155
|
+
if (env) {
|
|
1156
|
+
const envWorkspaceUrl = process.env.SLACK_WORKSPACE_URL?.trim();
|
|
1157
|
+
const urlForBrowser = workspaceUrl || envWorkspaceUrl;
|
|
1158
|
+
return {
|
|
1159
|
+
client: new SlackApiClient(env, { workspaceUrl: urlForBrowser }),
|
|
1160
|
+
auth: env,
|
|
1161
|
+
workspace_url: urlForBrowser
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
if (workspaceUrl) {
|
|
1165
|
+
const ws = await resolveWorkspaceForUrl(workspaceUrl);
|
|
1166
|
+
if (ws) {
|
|
1167
|
+
return {
|
|
1168
|
+
client: new SlackApiClient(ws.auth, {
|
|
1169
|
+
workspaceUrl: ws.workspace_url
|
|
1170
|
+
}),
|
|
1171
|
+
auth: ws.auth,
|
|
1172
|
+
workspace_url: ws.workspace_url
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
const def = await resolveDefaultWorkspace();
|
|
1177
|
+
if (def) {
|
|
1178
|
+
return {
|
|
1179
|
+
client: new SlackApiClient(def.auth, {
|
|
1180
|
+
workspaceUrl: def.workspace_url
|
|
1181
|
+
}),
|
|
1182
|
+
auth: def.auth,
|
|
1183
|
+
workspace_url: def.workspace_url
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
try {
|
|
1187
|
+
const extracted = await extractFromSlackDesktop();
|
|
1188
|
+
await upsertWorkspaces(extracted.teams.map((team) => ({
|
|
1189
|
+
workspace_url: normalizeUrl(team.url),
|
|
1190
|
+
workspace_name: team.name,
|
|
1191
|
+
auth: {
|
|
1192
|
+
auth_type: "browser",
|
|
1193
|
+
xoxc_token: team.token,
|
|
1194
|
+
xoxd_cookie: extracted.cookie_d
|
|
1195
|
+
}
|
|
1196
|
+
})));
|
|
1197
|
+
const desired = workspaceUrl ? await resolveWorkspaceForUrl(workspaceUrl) : await resolveDefaultWorkspace();
|
|
1198
|
+
const chosen = desired ?? await resolveDefaultWorkspace();
|
|
1199
|
+
if (chosen) {
|
|
1200
|
+
return {
|
|
1201
|
+
client: new SlackApiClient(chosen.auth, {
|
|
1202
|
+
workspaceUrl: chosen.workspace_url
|
|
1203
|
+
}),
|
|
1204
|
+
auth: chosen.auth,
|
|
1205
|
+
workspace_url: chosen.workspace_url
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
} catch {}
|
|
1209
|
+
const chrome = extractFromChrome();
|
|
1210
|
+
if (chrome && chrome.teams.length > 0) {
|
|
1211
|
+
const chosen = (workspaceUrl ? chrome.teams.find((t) => normalizeUrl(t.url) === normalizeUrl(workspaceUrl)) : null) ?? chrome.teams[0];
|
|
1212
|
+
const auth = {
|
|
1213
|
+
auth_type: "browser",
|
|
1214
|
+
xoxc_token: chosen.token,
|
|
1215
|
+
xoxd_cookie: chrome.cookie_d
|
|
1216
|
+
};
|
|
1217
|
+
await upsertWorkspace({
|
|
1218
|
+
workspace_url: normalizeUrl(chosen.url),
|
|
1219
|
+
workspace_name: chosen.name,
|
|
1220
|
+
auth: {
|
|
1221
|
+
auth_type: "browser",
|
|
1222
|
+
xoxc_token: chosen.token,
|
|
1223
|
+
xoxd_cookie: chrome.cookie_d
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
return {
|
|
1227
|
+
client: new SlackApiClient(auth, {
|
|
1228
|
+
workspaceUrl: normalizeUrl(chosen.url)
|
|
1229
|
+
}),
|
|
1230
|
+
auth,
|
|
1231
|
+
workspace_url: normalizeUrl(chosen.url)
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
throw new Error('No Slack credentials available. Try "agent-slack auth import-desktop" or set SLACK_TOKEN / SLACK_COOKIE_D.');
|
|
1235
|
+
}
|
|
1236
|
+
function createCliContext() {
|
|
1237
|
+
return {
|
|
1238
|
+
effectiveWorkspaceUrl,
|
|
1239
|
+
assertWorkspaceSpecifiedForChannelNames,
|
|
1240
|
+
withAutoRefresh,
|
|
1241
|
+
getClientForWorkspace,
|
|
1242
|
+
normalizeUrl,
|
|
1243
|
+
errorMessage,
|
|
1244
|
+
parseContentType,
|
|
1245
|
+
parseCurl: parseSlackCurlCommand,
|
|
1246
|
+
importDesktop: extractFromSlackDesktop,
|
|
1247
|
+
importChrome: extractFromChrome
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// src/lib/compact-json.ts
|
|
1252
|
+
function pruneEmpty(value) {
|
|
1253
|
+
const pruned = pruneEmptyInternal(value);
|
|
1254
|
+
return pruned === undefined ? {} : pruned;
|
|
1255
|
+
}
|
|
1256
|
+
function pruneEmptyInternal(value) {
|
|
1257
|
+
if (value === null || value === undefined) {
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (typeof value === "string") {
|
|
1261
|
+
return value.trim() === "" ? undefined : value;
|
|
1262
|
+
}
|
|
1263
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
1264
|
+
return value;
|
|
1265
|
+
}
|
|
1266
|
+
if (Array.isArray(value)) {
|
|
1267
|
+
const next = value.map((v) => pruneEmptyInternal(v)).filter((v) => v !== undefined);
|
|
1268
|
+
return next.length === 0 ? undefined : next;
|
|
1269
|
+
}
|
|
1270
|
+
if (typeof value === "object") {
|
|
1271
|
+
const out = {};
|
|
1272
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1273
|
+
const next = pruneEmptyInternal(v);
|
|
1274
|
+
if (next !== undefined) {
|
|
1275
|
+
out[k] = next;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
1279
|
+
}
|
|
1280
|
+
return value;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// src/lib/redact.ts
|
|
1284
|
+
function redactSecret(value, options) {
|
|
1285
|
+
const keepStart = options?.keepStart ?? 6;
|
|
1286
|
+
const keepEnd = options?.keepEnd ?? 4;
|
|
1287
|
+
if (!value) {
|
|
1288
|
+
return value;
|
|
1289
|
+
}
|
|
1290
|
+
if (value.length <= keepStart + keepEnd + 3) {
|
|
1291
|
+
return "[redacted]";
|
|
1292
|
+
}
|
|
1293
|
+
return `${value.slice(0, keepStart)}…${value.slice(-keepEnd)}`;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// src/cli/auth-command.ts
|
|
1297
|
+
async function runAuthTest(input) {
|
|
1298
|
+
return input.ctx.withAutoRefresh({
|
|
1299
|
+
workspaceUrl: input.workspaceUrl,
|
|
1300
|
+
work: async () => {
|
|
1301
|
+
const { client } = await input.ctx.getClientForWorkspace(input.workspaceUrl);
|
|
1302
|
+
return await client.api("auth.test", {});
|
|
1303
|
+
}
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
function registerAuthCommand(input) {
|
|
1307
|
+
const auth = input.program.command("auth").description("Manage Slack authentication");
|
|
1308
|
+
auth.command("whoami").description("Show configured workspaces and token sources").action(async () => {
|
|
1309
|
+
try {
|
|
1310
|
+
const creds = await loadCredentials();
|
|
1311
|
+
const sanitized = {
|
|
1312
|
+
...creds,
|
|
1313
|
+
workspaces: creds.workspaces.map((w) => ({
|
|
1314
|
+
workspace_url: w.workspace_url,
|
|
1315
|
+
workspace_name: w.workspace_name,
|
|
1316
|
+
auth_type: w.auth.auth_type,
|
|
1317
|
+
token: w.auth.auth_type === "standard" ? redactSecret(w.auth.token) : redactSecret(w.auth.xoxc_token),
|
|
1318
|
+
cookie_d: w.auth.auth_type === "browser" ? redactSecret(w.auth.xoxd_cookie) : undefined
|
|
1319
|
+
}))
|
|
1320
|
+
};
|
|
1321
|
+
console.log(JSON.stringify(pruneEmpty(sanitized), null, 2));
|
|
1322
|
+
} catch (err) {
|
|
1323
|
+
console.error(input.ctx.errorMessage(err));
|
|
1324
|
+
process.exitCode = 1;
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
auth.command("test").description("Verify credentials (calls Slack auth.test)").option("--workspace <url>", "Workspace URL (needed when you have multiple workspaces)").action(async (...args) => {
|
|
1328
|
+
const [options] = args;
|
|
1329
|
+
try {
|
|
1330
|
+
const resp = await runAuthTest({
|
|
1331
|
+
ctx: input.ctx,
|
|
1332
|
+
workspaceUrl: input.ctx.effectiveWorkspaceUrl(options.workspace)
|
|
1333
|
+
});
|
|
1334
|
+
console.log(JSON.stringify(pruneEmpty(resp), null, 2));
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
console.error(input.ctx.errorMessage(err));
|
|
1337
|
+
process.exitCode = 1;
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
auth.command("import-chrome").description("Import xoxc/xoxd from a logged-in Slack tab in Google Chrome (macOS)").action(async () => {
|
|
1341
|
+
try {
|
|
1342
|
+
const extracted = input.ctx.importChrome();
|
|
1343
|
+
if (!extracted) {
|
|
1344
|
+
throw new Error("Could not extract tokens from Chrome. Open Slack in Chrome and ensure you're logged in.");
|
|
1345
|
+
}
|
|
1346
|
+
for (const team of extracted.teams) {
|
|
1347
|
+
await upsertWorkspace({
|
|
1348
|
+
workspace_url: input.ctx.normalizeUrl(team.url),
|
|
1349
|
+
workspace_name: team.name,
|
|
1350
|
+
auth: {
|
|
1351
|
+
auth_type: "browser",
|
|
1352
|
+
xoxc_token: team.token,
|
|
1353
|
+
xoxd_cookie: extracted.cookie_d
|
|
1354
|
+
}
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
console.log(`Imported ${extracted.teams.length} workspace token(s) from Chrome.`);
|
|
1358
|
+
} catch (err) {
|
|
1359
|
+
console.error(input.ctx.errorMessage(err));
|
|
1360
|
+
process.exitCode = 1;
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
auth.command("parse-curl").description("Paste a Slack API request copied as cURL (extracts xoxc/xoxd and saves locally)").action(async () => {
|
|
1364
|
+
try {
|
|
1365
|
+
const curlInput = await new Response(process.stdin).text();
|
|
1366
|
+
if (!curlInput.trim()) {
|
|
1367
|
+
throw new Error("Expected cURL command on stdin");
|
|
1368
|
+
}
|
|
1369
|
+
const parsed = input.ctx.parseCurl(curlInput);
|
|
1370
|
+
await upsertWorkspace({
|
|
1371
|
+
workspace_url: input.ctx.normalizeUrl(parsed.workspace_url),
|
|
1372
|
+
auth: {
|
|
1373
|
+
auth_type: "browser",
|
|
1374
|
+
xoxc_token: parsed.xoxc_token,
|
|
1375
|
+
xoxd_cookie: parsed.xoxd_cookie
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1378
|
+
console.log(`Imported tokens for ${input.ctx.normalizeUrl(parsed.workspace_url)}.`);
|
|
1379
|
+
} catch (err) {
|
|
1380
|
+
console.error(input.ctx.errorMessage(err));
|
|
1381
|
+
process.exitCode = 1;
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
auth.command("import-desktop").description("Import xoxc token(s) + d cookie from Slack Desktop data (TypeScript; no need to quit Slack)").action(async () => {
|
|
1385
|
+
try {
|
|
1386
|
+
const extracted = await input.ctx.importDesktop();
|
|
1387
|
+
await upsertWorkspaces(extracted.teams.map((team) => ({
|
|
1388
|
+
workspace_url: input.ctx.normalizeUrl(team.url),
|
|
1389
|
+
workspace_name: team.name,
|
|
1390
|
+
auth: {
|
|
1391
|
+
auth_type: "browser",
|
|
1392
|
+
xoxc_token: team.token,
|
|
1393
|
+
xoxd_cookie: extracted.cookie_d
|
|
1394
|
+
}
|
|
1395
|
+
})));
|
|
1396
|
+
const payload = {
|
|
1397
|
+
imported: extracted.teams.length,
|
|
1398
|
+
source: extracted.source,
|
|
1399
|
+
workspaces: extracted.teams.map((t) => ({
|
|
1400
|
+
workspace_url: input.ctx.normalizeUrl(t.url),
|
|
1401
|
+
workspace_name: t.name
|
|
1402
|
+
}))
|
|
1403
|
+
};
|
|
1404
|
+
console.log(JSON.stringify(pruneEmpty(payload), null, 2));
|
|
1405
|
+
} catch (err) {
|
|
1406
|
+
console.error(input.ctx.errorMessage(err));
|
|
1407
|
+
process.exitCode = 1;
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
auth.command("add").description("Add credentials (standard token or browser xoxc/xoxd)").requiredOption("--workspace-url <url>", "Workspace URL like https://myteam.slack.com").option("--token <token>", "Standard Slack token (xoxb/xoxp)").option("--xoxc <token>", "Browser token (xoxc-...)").option("--xoxd <cookie>", "Browser cookie d (xoxd-...)").action(async (...args) => {
|
|
1411
|
+
const [options] = args;
|
|
1412
|
+
try {
|
|
1413
|
+
const workspaceUrl = input.ctx.normalizeUrl(options.workspaceUrl);
|
|
1414
|
+
if (options.token) {
|
|
1415
|
+
await upsertWorkspace({
|
|
1416
|
+
workspace_url: workspaceUrl,
|
|
1417
|
+
auth: { auth_type: "standard", token: options.token }
|
|
1418
|
+
});
|
|
1419
|
+
console.log("Saved standard token.");
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
if (options.xoxc && options.xoxd) {
|
|
1423
|
+
await upsertWorkspace({
|
|
1424
|
+
workspace_url: workspaceUrl,
|
|
1425
|
+
auth: {
|
|
1426
|
+
auth_type: "browser",
|
|
1427
|
+
xoxc_token: options.xoxc,
|
|
1428
|
+
xoxd_cookie: options.xoxd
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
console.log("Saved browser tokens.");
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
throw new Error("Provide either --token or both --xoxc and --xoxd");
|
|
1435
|
+
} catch (err) {
|
|
1436
|
+
console.error(input.ctx.errorMessage(err));
|
|
1437
|
+
process.exitCode = 1;
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
auth.command("set-default").description("Set the default workspace URL").argument("<workspace-url>", "Workspace URL like https://myteam.slack.com").action(async (...args) => {
|
|
1441
|
+
const [workspaceUrl] = args;
|
|
1442
|
+
try {
|
|
1443
|
+
await setDefaultWorkspace(workspaceUrl);
|
|
1444
|
+
console.log("Default workspace updated.");
|
|
1445
|
+
} catch (err) {
|
|
1446
|
+
console.error(input.ctx.errorMessage(err));
|
|
1447
|
+
process.exitCode = 1;
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
auth.command("remove").description("Remove a workspace from local config").argument("<workspace-url>", "Workspace URL like https://myteam.slack.com").action(async (...args) => {
|
|
1451
|
+
const [workspaceUrl] = args;
|
|
1452
|
+
try {
|
|
1453
|
+
await removeWorkspace(workspaceUrl);
|
|
1454
|
+
console.log("Removed workspace.");
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
console.error(input.ctx.errorMessage(err));
|
|
1457
|
+
process.exitCode = 1;
|
|
1458
|
+
}
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// src/slack/files.ts
|
|
1463
|
+
import { mkdir as mkdir3, writeFile as writeFile2 } from "node:fs/promises";
|
|
1464
|
+
import { basename, join as join5, resolve } from "node:path";
|
|
1465
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
1466
|
+
async function downloadSlackFile(input) {
|
|
1467
|
+
const { auth, url, destDir, preferredName, options } = input;
|
|
1468
|
+
const absDir = resolve(destDir);
|
|
1469
|
+
await mkdir3(absDir, { recursive: true });
|
|
1470
|
+
const name = sanitizeFilename(preferredName || basename(new URL(url).pathname) || "file");
|
|
1471
|
+
const path = join5(absDir, name);
|
|
1472
|
+
if (existsSync3(path)) {
|
|
1473
|
+
return path;
|
|
1474
|
+
}
|
|
1475
|
+
const headers = {};
|
|
1476
|
+
if (auth.auth_type === "standard") {
|
|
1477
|
+
headers.Authorization = `Bearer ${auth.token}`;
|
|
1478
|
+
} else {
|
|
1479
|
+
headers.Authorization = `Bearer ${auth.xoxc_token}`;
|
|
1480
|
+
headers.Cookie = `d=${encodeURIComponent(auth.xoxd_cookie)}`;
|
|
1481
|
+
headers.Referer = "https://app.slack.com/";
|
|
1482
|
+
headers["User-Agent"] = getUserAgent();
|
|
1483
|
+
}
|
|
1484
|
+
const resp = await fetch(url, { headers });
|
|
1485
|
+
if (!resp.ok) {
|
|
1486
|
+
throw new Error(`Failed to download file (${resp.status})`);
|
|
1487
|
+
}
|
|
1488
|
+
const contentType = resp.headers.get("content-type") || "";
|
|
1489
|
+
if (!options?.allowHtml && contentType.includes("text/html")) {
|
|
1490
|
+
const text = await resp.text();
|
|
1491
|
+
throw new Error(`Downloaded HTML instead of file (auth likely failed). First bytes: ${JSON.stringify(text.slice(0, 120))}`);
|
|
1492
|
+
}
|
|
1493
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
1494
|
+
await writeFile2(path, buf);
|
|
1495
|
+
return path;
|
|
1496
|
+
}
|
|
1497
|
+
function sanitizeFilename(name) {
|
|
1498
|
+
return name.replace(/[\\/<>:"|?*]/g, "_");
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// src/slack/html-to-md.ts
|
|
1502
|
+
import TurndownService from "turndown";
|
|
1503
|
+
import { gfm } from "turndown-plugin-gfm";
|
|
1504
|
+
function htmlToMarkdown(html) {
|
|
1505
|
+
const service = new TurndownService({
|
|
1506
|
+
headingStyle: "atx",
|
|
1507
|
+
codeBlockStyle: "fenced",
|
|
1508
|
+
bulletListMarker: "-",
|
|
1509
|
+
emDelimiter: "_"
|
|
1510
|
+
});
|
|
1511
|
+
service.use(gfm);
|
|
1512
|
+
service.addRule("br", {
|
|
1513
|
+
filter: "br",
|
|
1514
|
+
replacement: () => `
|
|
1515
|
+
`
|
|
1516
|
+
});
|
|
1517
|
+
const extracted = extractTag(html, "main") ?? extractTag(html, "article") ?? extractTag(html, "body") ?? html;
|
|
1518
|
+
return service.turndown(extracted);
|
|
1519
|
+
}
|
|
1520
|
+
function extractTag(html, tag) {
|
|
1521
|
+
const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i");
|
|
1522
|
+
const m = html.match(re);
|
|
1523
|
+
return m ? m[1] : null;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// src/lib/tmp-paths.ts
|
|
1527
|
+
import { join as join7, resolve as resolve2 } from "node:path";
|
|
1528
|
+
import { mkdir as mkdir4 } from "node:fs/promises";
|
|
1529
|
+
|
|
1530
|
+
// src/lib/app-dir.ts
|
|
1531
|
+
import { homedir as homedir3, tmpdir } from "node:os";
|
|
1532
|
+
import { join as join6 } from "node:path";
|
|
1533
|
+
function getAppDir() {
|
|
1534
|
+
const xdg = process.env.XDG_RUNTIME_DIR?.trim();
|
|
1535
|
+
if (xdg) {
|
|
1536
|
+
return join6(xdg, "agent-slack");
|
|
1537
|
+
}
|
|
1538
|
+
const home = homedir3();
|
|
1539
|
+
if (home) {
|
|
1540
|
+
return join6(home, ".agent-slack");
|
|
1541
|
+
}
|
|
1542
|
+
return join6(tmpdir(), "agent-slack");
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// src/lib/tmp-paths.ts
|
|
1546
|
+
function getDownloadsDir() {
|
|
1547
|
+
return resolve2(join7(getAppDir(), "tmp", "downloads"));
|
|
1548
|
+
}
|
|
1549
|
+
async function ensureDownloadsDir() {
|
|
1550
|
+
const dir = getDownloadsDir();
|
|
1551
|
+
await mkdir4(dir, { recursive: true });
|
|
1552
|
+
return dir;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// src/slack/canvas.ts
|
|
1556
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
1557
|
+
function parseSlackCanvasUrl(input) {
|
|
1558
|
+
let url;
|
|
1559
|
+
try {
|
|
1560
|
+
url = new URL(input);
|
|
1561
|
+
} catch {
|
|
1562
|
+
throw new Error(`Invalid URL: ${input}`);
|
|
1563
|
+
}
|
|
1564
|
+
if (!/\.slack\.com$/i.test(url.hostname)) {
|
|
1565
|
+
throw new Error(`Not a Slack workspace URL: ${url.hostname}`);
|
|
1566
|
+
}
|
|
1567
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
1568
|
+
if (parts[0] !== "docs") {
|
|
1569
|
+
throw new Error(`Unsupported Slack canvas URL path: ${url.pathname}`);
|
|
1570
|
+
}
|
|
1571
|
+
const canvas_id = parts.find((p) => /^F[A-Z0-9]{8,}$/.test(p));
|
|
1572
|
+
if (!canvas_id) {
|
|
1573
|
+
throw new Error(`Could not find canvas id in: ${url.pathname}`);
|
|
1574
|
+
}
|
|
1575
|
+
const workspace_url = `${url.protocol}//${url.host}`;
|
|
1576
|
+
return { workspace_url, canvas_id, raw: input };
|
|
1577
|
+
}
|
|
1578
|
+
async function fetchCanvasMarkdown(client, input) {
|
|
1579
|
+
const info = await client.api("files.info", { file: input.canvasId });
|
|
1580
|
+
const file = isRecord6(info.file) ? info.file : null;
|
|
1581
|
+
if (!file) {
|
|
1582
|
+
throw new Error("Canvas not found (files.info returned no file)");
|
|
1583
|
+
}
|
|
1584
|
+
const title = (getString2(file.title) || getString2(file.name) || "").trim() || undefined;
|
|
1585
|
+
const downloadUrl = getString2(file.url_private_download) ?? getString2(file.url_private);
|
|
1586
|
+
if (!downloadUrl) {
|
|
1587
|
+
throw new Error("Canvas has no download URL");
|
|
1588
|
+
}
|
|
1589
|
+
let html = "";
|
|
1590
|
+
if (input.options?.downloadHtml ?? true) {
|
|
1591
|
+
const htmlPath = await downloadSlackFile({
|
|
1592
|
+
auth: input.auth,
|
|
1593
|
+
url: downloadUrl,
|
|
1594
|
+
destDir: await ensureDownloadsDir(),
|
|
1595
|
+
preferredName: `${input.canvasId}.html`,
|
|
1596
|
+
options: { allowHtml: true }
|
|
1597
|
+
});
|
|
1598
|
+
html = await readFile3(htmlPath, "utf8");
|
|
1599
|
+
} else {
|
|
1600
|
+
const headers = {};
|
|
1601
|
+
if (input.auth.auth_type === "standard") {
|
|
1602
|
+
headers.Authorization = `Bearer ${input.auth.token}`;
|
|
1603
|
+
} else {
|
|
1604
|
+
headers.Authorization = `Bearer ${input.auth.xoxc_token}`;
|
|
1605
|
+
headers.Cookie = `d=${encodeURIComponent(input.auth.xoxd_cookie)}`;
|
|
1606
|
+
headers.Referer = "https://app.slack.com/";
|
|
1607
|
+
headers["User-Agent"] = getUserAgent();
|
|
1608
|
+
}
|
|
1609
|
+
const resp = await fetch(downloadUrl, { headers });
|
|
1610
|
+
if (!resp.ok) {
|
|
1611
|
+
throw new Error(`Failed to download canvas HTML (${resp.status})`);
|
|
1612
|
+
}
|
|
1613
|
+
html = await resp.text();
|
|
1614
|
+
}
|
|
1615
|
+
const markdownRaw = htmlToMarkdown(html).trim();
|
|
1616
|
+
const maxChars = input.options?.maxChars ?? 20000;
|
|
1617
|
+
const markdown = maxChars >= 0 && markdownRaw.length > maxChars ? `${markdownRaw.slice(0, maxChars)}
|
|
1618
|
+
…` : markdownRaw;
|
|
1619
|
+
return {
|
|
1620
|
+
canvas: {
|
|
1621
|
+
id: input.canvasId,
|
|
1622
|
+
title,
|
|
1623
|
+
markdown
|
|
1624
|
+
}
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
function isRecord6(value) {
|
|
1628
|
+
return typeof value === "object" && value !== null;
|
|
1629
|
+
}
|
|
1630
|
+
function getString2(value) {
|
|
1631
|
+
return typeof value === "string" ? value : undefined;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// src/cli/canvas-command.ts
|
|
1635
|
+
function registerCanvasCommand(input) {
|
|
1636
|
+
const canvasCmd = input.program.command("canvas").description("Work with Slack canvases");
|
|
1637
|
+
canvasCmd.command("get").description("Fetch a Slack canvas and convert it to Markdown").argument("<canvas>", "Slack canvas URL (…/docs/…/F…) or canvas id (F…)").option("--workspace <url>", "Workspace URL (required if passing a canvas id and you have multiple workspaces)").option("--max-chars <n>", "Max markdown characters to include (default 20000, -1 for unlimited)", "20000").action(async (...args) => {
|
|
1638
|
+
const [value, options] = args;
|
|
1639
|
+
try {
|
|
1640
|
+
let workspaceUrl;
|
|
1641
|
+
let canvasId;
|
|
1642
|
+
try {
|
|
1643
|
+
const ref = parseSlackCanvasUrl(value);
|
|
1644
|
+
workspaceUrl = ref.workspace_url;
|
|
1645
|
+
canvasId = ref.canvas_id;
|
|
1646
|
+
} catch {
|
|
1647
|
+
const trimmed = String(value).trim();
|
|
1648
|
+
if (!/^F[A-Z0-9]{8,}$/.test(trimmed)) {
|
|
1649
|
+
throw new Error(`Unsupported canvas input: ${value} (expected Slack canvas URL or id like F...)`);
|
|
1650
|
+
}
|
|
1651
|
+
canvasId = trimmed;
|
|
1652
|
+
workspaceUrl = options.workspace?.trim() || undefined;
|
|
1653
|
+
}
|
|
1654
|
+
const payload = await input.ctx.withAutoRefresh({
|
|
1655
|
+
workspaceUrl,
|
|
1656
|
+
work: async () => {
|
|
1657
|
+
const { client, auth, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
|
|
1658
|
+
const maxChars = Number.parseInt(options.maxChars, 10);
|
|
1659
|
+
return await fetchCanvasMarkdown(client, {
|
|
1660
|
+
auth,
|
|
1661
|
+
workspaceUrl: workspace_url ?? workspaceUrl ?? "",
|
|
1662
|
+
canvasId,
|
|
1663
|
+
options: { maxChars }
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
});
|
|
1667
|
+
console.log(JSON.stringify(pruneEmpty(payload), null, 2));
|
|
1668
|
+
} catch (err) {
|
|
1669
|
+
console.error(input.ctx.errorMessage(err));
|
|
1670
|
+
process.exitCode = 1;
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// src/slack/emoji.ts
|
|
1676
|
+
import * as emoji from "node-emoji";
|
|
1677
|
+
function slackEmojiShortcodesToUnicode(text) {
|
|
1678
|
+
if (!text) {
|
|
1679
|
+
return "";
|
|
1680
|
+
}
|
|
1681
|
+
return emoji.emojify(text);
|
|
1682
|
+
}
|
|
1683
|
+
function normalizeSlackReactionName(input) {
|
|
1684
|
+
const trimmed = String(input ?? "").trim();
|
|
1685
|
+
if (!trimmed) {
|
|
1686
|
+
throw new Error("Emoji is empty");
|
|
1687
|
+
}
|
|
1688
|
+
const shortcodeMatch = trimmed.match(/^:([^:\s]+):$/);
|
|
1689
|
+
if (shortcodeMatch) {
|
|
1690
|
+
return shortcodeMatch[1];
|
|
1691
|
+
}
|
|
1692
|
+
if (/^[A-Za-z0-9_+-]+$/.test(trimmed)) {
|
|
1693
|
+
return trimmed;
|
|
1694
|
+
}
|
|
1695
|
+
const viaNodeEmoji = emoji.which(trimmed);
|
|
1696
|
+
if (viaNodeEmoji) {
|
|
1697
|
+
return viaNodeEmoji;
|
|
1698
|
+
}
|
|
1699
|
+
throw new Error(`Unsupported emoji format: ${JSON.stringify(input)} (use :emoji: or unicode emoji)`);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// src/slack/mrkdwn.ts
|
|
1703
|
+
function slackMrkdwnToMarkdown(text) {
|
|
1704
|
+
if (!text) {
|
|
1705
|
+
return "";
|
|
1706
|
+
}
|
|
1707
|
+
let out = text.replace(/<((https?:\/\/)[^>|]+)\|([^>]+)>/g, "[$3]($1)");
|
|
1708
|
+
out = out.replace(/<((https?:\/\/)[^>]+)>/g, "$1");
|
|
1709
|
+
out = out.replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1");
|
|
1710
|
+
out = out.replace(/<@([A-Z0-9]+)\|([^>]+)>/g, "@$2");
|
|
1711
|
+
out = out.replace(/<@([A-Z0-9]+)>/g, "@$1");
|
|
1712
|
+
out = out.replace(/<!([a-zA-Z]+)>/g, "@$1");
|
|
1713
|
+
out = out.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
1714
|
+
out = slackEmojiShortcodesToUnicode(out);
|
|
1715
|
+
return out;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// src/slack/render.ts
|
|
1719
|
+
function renderSlackMessageContent(msg) {
|
|
1720
|
+
const msgObj = isRecord7(msg) ? msg : {};
|
|
1721
|
+
const blockMrkdwn = extractMrkdwnFromBlocks(msgObj.blocks);
|
|
1722
|
+
if (blockMrkdwn.trim()) {
|
|
1723
|
+
return slackMrkdwnToMarkdown(blockMrkdwn).trim();
|
|
1724
|
+
}
|
|
1725
|
+
const attachmentMrkdwn = extractMrkdwnFromAttachments(msgObj.attachments);
|
|
1726
|
+
if (attachmentMrkdwn.trim()) {
|
|
1727
|
+
return slackMrkdwnToMarkdown(attachmentMrkdwn).trim();
|
|
1728
|
+
}
|
|
1729
|
+
const text = getString3(msgObj.text).trim();
|
|
1730
|
+
if (text) {
|
|
1731
|
+
return slackMrkdwnToMarkdown(text).trim();
|
|
1732
|
+
}
|
|
1733
|
+
return "";
|
|
1734
|
+
}
|
|
1735
|
+
function extractMrkdwnFromBlocks(blocks) {
|
|
1736
|
+
if (!Array.isArray(blocks)) {
|
|
1737
|
+
return "";
|
|
1738
|
+
}
|
|
1739
|
+
const out = [];
|
|
1740
|
+
for (const b of blocks) {
|
|
1741
|
+
if (!isRecord7(b)) {
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
const type = getString3(b.type);
|
|
1745
|
+
if (type === "section") {
|
|
1746
|
+
const text = isRecord7(b.text) ? b.text : null;
|
|
1747
|
+
const textType = text ? getString3(text.type) : "";
|
|
1748
|
+
if (textType === "mrkdwn" || textType === "plain_text") {
|
|
1749
|
+
out.push(getString3(text?.text));
|
|
1750
|
+
}
|
|
1751
|
+
if (Array.isArray(b.fields)) {
|
|
1752
|
+
for (const f of b.fields) {
|
|
1753
|
+
if (!isRecord7(f)) {
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
const fieldType = getString3(f.type);
|
|
1757
|
+
if (fieldType === "mrkdwn" || fieldType === "plain_text") {
|
|
1758
|
+
out.push(getString3(f.text));
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
const accessory = isRecord7(b.accessory) ? b.accessory : null;
|
|
1763
|
+
if (getString3(accessory?.type) === "button") {
|
|
1764
|
+
const label = getString3(accessory?.text?.text);
|
|
1765
|
+
const url = getString3(accessory?.url);
|
|
1766
|
+
if (url) {
|
|
1767
|
+
out.push(label ? `${label}: ${url}` : url);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
continue;
|
|
1771
|
+
}
|
|
1772
|
+
if (type === "actions" && Array.isArray(b.elements)) {
|
|
1773
|
+
for (const el of b.elements) {
|
|
1774
|
+
if (!isRecord7(el)) {
|
|
1775
|
+
continue;
|
|
1776
|
+
}
|
|
1777
|
+
if (getString3(el.type) === "button") {
|
|
1778
|
+
const label = getString3(el.text?.text);
|
|
1779
|
+
const url = getString3(el.url);
|
|
1780
|
+
if (url) {
|
|
1781
|
+
out.push(label ? `${label}: ${url}` : url);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
continue;
|
|
1786
|
+
}
|
|
1787
|
+
if (type === "context" && Array.isArray(b.elements)) {
|
|
1788
|
+
for (const el of b.elements) {
|
|
1789
|
+
if (!isRecord7(el)) {
|
|
1790
|
+
continue;
|
|
1791
|
+
}
|
|
1792
|
+
const elType = getString3(el.type);
|
|
1793
|
+
if (elType === "mrkdwn") {
|
|
1794
|
+
out.push(getString3(el.text));
|
|
1795
|
+
}
|
|
1796
|
+
if (elType === "plain_text") {
|
|
1797
|
+
out.push(getString3(el.text));
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1802
|
+
if (type === "image") {
|
|
1803
|
+
const alt = getString3(b.alt_text);
|
|
1804
|
+
const url = getString3(b.image_url);
|
|
1805
|
+
if (url) {
|
|
1806
|
+
out.push(alt ? `${alt}: ${url}` : url);
|
|
1807
|
+
}
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
if (type === "rich_text") {
|
|
1811
|
+
const rich = extractMrkdwnFromRichTextBlock(b);
|
|
1812
|
+
if (rich.trim()) {
|
|
1813
|
+
out.push(rich);
|
|
1814
|
+
}
|
|
1815
|
+
continue;
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
return out.join(`
|
|
1819
|
+
|
|
1820
|
+
`);
|
|
1821
|
+
}
|
|
1822
|
+
function extractMrkdwnFromRichTextBlock(block) {
|
|
1823
|
+
if (!isRecord7(block)) {
|
|
1824
|
+
return "";
|
|
1825
|
+
}
|
|
1826
|
+
const elements = Array.isArray(block.elements) ? block.elements : [];
|
|
1827
|
+
const out = [];
|
|
1828
|
+
for (const el of elements) {
|
|
1829
|
+
const txt = extractMrkdwnFromRichTextElement(el);
|
|
1830
|
+
if (txt.trim()) {
|
|
1831
|
+
out.push(txt);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
return out.join(`
|
|
1835
|
+
|
|
1836
|
+
`);
|
|
1837
|
+
}
|
|
1838
|
+
function extractMrkdwnFromRichTextElement(el) {
|
|
1839
|
+
if (!isRecord7(el)) {
|
|
1840
|
+
return "";
|
|
1841
|
+
}
|
|
1842
|
+
const t = getString3(el.type);
|
|
1843
|
+
if (t === "rich_text_section") {
|
|
1844
|
+
const parts = [];
|
|
1845
|
+
for (const child of Array.isArray(el.elements) ? el.elements : []) {
|
|
1846
|
+
parts.push(extractMrkdwnFromRichTextElement(child));
|
|
1847
|
+
}
|
|
1848
|
+
return parts.join("");
|
|
1849
|
+
}
|
|
1850
|
+
if (t === "rich_text_preformatted") {
|
|
1851
|
+
const parts = [];
|
|
1852
|
+
for (const child of Array.isArray(el.elements) ? el.elements : []) {
|
|
1853
|
+
parts.push(extractMrkdwnFromRichTextElement(child));
|
|
1854
|
+
}
|
|
1855
|
+
const text = parts.join("");
|
|
1856
|
+
return text ? `\`\`\`${text}\`\`\`` : "";
|
|
1857
|
+
}
|
|
1858
|
+
if (t === "rich_text_quote") {
|
|
1859
|
+
const parts = [];
|
|
1860
|
+
for (const child of Array.isArray(el.elements) ? el.elements : []) {
|
|
1861
|
+
parts.push(extractMrkdwnFromRichTextElement(child));
|
|
1862
|
+
}
|
|
1863
|
+
const text = parts.join("").trim();
|
|
1864
|
+
if (!text) {
|
|
1865
|
+
return "";
|
|
1866
|
+
}
|
|
1867
|
+
return text.split(`
|
|
1868
|
+
`).map((line) => `> ${line}`).join(`
|
|
1869
|
+
`);
|
|
1870
|
+
}
|
|
1871
|
+
if (t === "rich_text_list") {
|
|
1872
|
+
const style = typeof el.style === "string" ? el.style : "bullet";
|
|
1873
|
+
const items = [];
|
|
1874
|
+
const itemEls = Array.isArray(el.elements) ? el.elements : [];
|
|
1875
|
+
for (let idx = 0;idx < itemEls.length; idx++) {
|
|
1876
|
+
const item = itemEls[idx];
|
|
1877
|
+
const txt = extractMrkdwnFromRichTextElement(item).trim();
|
|
1878
|
+
if (!txt) {
|
|
1879
|
+
continue;
|
|
1880
|
+
}
|
|
1881
|
+
const prefix = style === "ordered" ? `${idx + 1}. ` : "- ";
|
|
1882
|
+
items.push(`${prefix}${txt}`);
|
|
1883
|
+
}
|
|
1884
|
+
return items.join(`
|
|
1885
|
+
`);
|
|
1886
|
+
}
|
|
1887
|
+
if (t === "text") {
|
|
1888
|
+
const raw = getString3(el.text);
|
|
1889
|
+
const style = isRecord7(el.style) ? el.style : null;
|
|
1890
|
+
if (!style) {
|
|
1891
|
+
return raw;
|
|
1892
|
+
}
|
|
1893
|
+
let text = raw;
|
|
1894
|
+
if (style.code) {
|
|
1895
|
+
text = `\`${text}\``;
|
|
1896
|
+
}
|
|
1897
|
+
if (style.bold) {
|
|
1898
|
+
text = `*${text}*`;
|
|
1899
|
+
}
|
|
1900
|
+
if (style.italic) {
|
|
1901
|
+
text = `_${text}_`;
|
|
1902
|
+
}
|
|
1903
|
+
if (style.strike) {
|
|
1904
|
+
text = `~${text}~`;
|
|
1905
|
+
}
|
|
1906
|
+
return text;
|
|
1907
|
+
}
|
|
1908
|
+
if (t === "link") {
|
|
1909
|
+
const url = getString3(el.url);
|
|
1910
|
+
const text = getString3(el.text);
|
|
1911
|
+
if (!url) {
|
|
1912
|
+
return text;
|
|
1913
|
+
}
|
|
1914
|
+
return text ? `<${url}|${text}>` : url;
|
|
1915
|
+
}
|
|
1916
|
+
if (t === "emoji") {
|
|
1917
|
+
const name = getString3(el.name);
|
|
1918
|
+
return name ? `:${name}:` : "";
|
|
1919
|
+
}
|
|
1920
|
+
if (t === "user") {
|
|
1921
|
+
const userId = getString3(el.user_id);
|
|
1922
|
+
return userId ? `<@${userId}>` : "";
|
|
1923
|
+
}
|
|
1924
|
+
if (t === "channel") {
|
|
1925
|
+
const channelId = getString3(el.channel_id);
|
|
1926
|
+
return channelId ? `<#${channelId}>` : "";
|
|
1927
|
+
}
|
|
1928
|
+
return "";
|
|
1929
|
+
}
|
|
1930
|
+
function extractMrkdwnFromAttachments(attachments) {
|
|
1931
|
+
if (!Array.isArray(attachments)) {
|
|
1932
|
+
return "";
|
|
1933
|
+
}
|
|
1934
|
+
const parts = [];
|
|
1935
|
+
for (const a of attachments) {
|
|
1936
|
+
if (!isRecord7(a)) {
|
|
1937
|
+
continue;
|
|
1938
|
+
}
|
|
1939
|
+
const chunk = [];
|
|
1940
|
+
const blocks = extractMrkdwnFromBlocks(a.blocks);
|
|
1941
|
+
if (blocks.trim()) {
|
|
1942
|
+
chunk.push(blocks);
|
|
1943
|
+
}
|
|
1944
|
+
const pretext = getString3(a.pretext);
|
|
1945
|
+
if (pretext) {
|
|
1946
|
+
chunk.push(pretext);
|
|
1947
|
+
}
|
|
1948
|
+
const title = getString3(a.title);
|
|
1949
|
+
const titleLink = getString3(a.title_link);
|
|
1950
|
+
if (titleLink && title) {
|
|
1951
|
+
chunk.push(`<${titleLink}|${title}>`);
|
|
1952
|
+
} else if (title) {
|
|
1953
|
+
chunk.push(title);
|
|
1954
|
+
} else if (titleLink) {
|
|
1955
|
+
chunk.push(titleLink);
|
|
1956
|
+
}
|
|
1957
|
+
const text = getString3(a.text);
|
|
1958
|
+
if (text) {
|
|
1959
|
+
chunk.push(text);
|
|
1960
|
+
}
|
|
1961
|
+
if (Array.isArray(a.fields)) {
|
|
1962
|
+
for (const f of a.fields) {
|
|
1963
|
+
if (!isRecord7(f)) {
|
|
1964
|
+
continue;
|
|
1965
|
+
}
|
|
1966
|
+
const fieldTitle = getString3(f.title);
|
|
1967
|
+
const value = getString3(f.value);
|
|
1968
|
+
if (fieldTitle && value) {
|
|
1969
|
+
chunk.push(`${fieldTitle}
|
|
1970
|
+
${value}`);
|
|
1971
|
+
} else if (fieldTitle) {
|
|
1972
|
+
chunk.push(fieldTitle);
|
|
1973
|
+
} else if (value) {
|
|
1974
|
+
chunk.push(value);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
const fallback = getString3(a.fallback);
|
|
1979
|
+
if (chunk.length === 0 && fallback) {
|
|
1980
|
+
chunk.push(fallback);
|
|
1981
|
+
}
|
|
1982
|
+
if (chunk.length > 0) {
|
|
1983
|
+
parts.push(chunk.join(`
|
|
1984
|
+
`));
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
return parts.join(`
|
|
1988
|
+
|
|
1989
|
+
`);
|
|
1990
|
+
}
|
|
1991
|
+
function isRecord7(value) {
|
|
1992
|
+
return typeof value === "object" && value !== null;
|
|
1993
|
+
}
|
|
1994
|
+
function getString3(value) {
|
|
1995
|
+
return typeof value === "string" ? value : "";
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// src/slack/messages.ts
|
|
1999
|
+
function toCompactMessage(msg, input) {
|
|
2000
|
+
const maxBodyChars = input?.maxBodyChars ?? 8000;
|
|
2001
|
+
const includeReactions = input?.includeReactions ?? false;
|
|
2002
|
+
const rendered = renderSlackMessageContent(msg);
|
|
2003
|
+
const content = maxBodyChars >= 0 && rendered.length > maxBodyChars ? `${rendered.slice(0, maxBodyChars)}
|
|
2004
|
+
…` : rendered;
|
|
2005
|
+
const files = msg.files?.map((f) => {
|
|
2006
|
+
const path = input?.downloadedPaths?.[f.id];
|
|
2007
|
+
if (!path) {
|
|
2008
|
+
return null;
|
|
2009
|
+
}
|
|
2010
|
+
return {
|
|
2011
|
+
mimetype: f.mimetype,
|
|
2012
|
+
mode: f.mode,
|
|
2013
|
+
path
|
|
2014
|
+
};
|
|
2015
|
+
}).filter((f) => Boolean(f));
|
|
2016
|
+
return {
|
|
2017
|
+
channel_id: msg.channel_id,
|
|
2018
|
+
ts: msg.ts,
|
|
2019
|
+
thread_ts: msg.thread_ts ?? ((msg.reply_count ?? 0) > 0 ? msg.ts : undefined),
|
|
2020
|
+
author: msg.user || msg.bot_id ? { user_id: msg.user, bot_id: msg.bot_id } : undefined,
|
|
2021
|
+
content: content ? content : undefined,
|
|
2022
|
+
files: files && files.length > 0 ? files : undefined,
|
|
2023
|
+
reactions: includeReactions ? compactReactions(msg.reactions) : undefined
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
function compactReactions(reactions) {
|
|
2027
|
+
if (!Array.isArray(reactions) || reactions.length === 0) {
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const out = [];
|
|
2031
|
+
for (const r of reactions) {
|
|
2032
|
+
if (!isRecord8(r)) {
|
|
2033
|
+
continue;
|
|
2034
|
+
}
|
|
2035
|
+
const name = getString4(r.name)?.trim() ?? "";
|
|
2036
|
+
if (!name) {
|
|
2037
|
+
continue;
|
|
2038
|
+
}
|
|
2039
|
+
const users = Array.isArray(r.users) ? r.users.map((u) => String(u)).filter((u) => /^U[A-Z0-9]{8,}$/.test(u)) : [];
|
|
2040
|
+
const count = typeof r.count === "number" && r.count !== users.length ? r.count : undefined;
|
|
2041
|
+
out.push({ name, users, count });
|
|
2042
|
+
}
|
|
2043
|
+
return out.length ? out : undefined;
|
|
2044
|
+
}
|
|
2045
|
+
async function fetchMessage(client, input) {
|
|
2046
|
+
const history = await client.api("conversations.history", {
|
|
2047
|
+
channel: input.ref.channel_id,
|
|
2048
|
+
latest: input.ref.message_ts,
|
|
2049
|
+
inclusive: true,
|
|
2050
|
+
limit: 5,
|
|
2051
|
+
include_all_metadata: input.includeReactions ? true : undefined
|
|
2052
|
+
});
|
|
2053
|
+
const historyMessages = asArray2(history.messages);
|
|
2054
|
+
let msg = historyMessages.find((m) => isRecord8(m) && getString4(m.ts) === input.ref.message_ts);
|
|
2055
|
+
if (!msg && input.ref.thread_ts_hint) {
|
|
2056
|
+
msg = await findMessageInThread(client, {
|
|
2057
|
+
channelId: input.ref.channel_id,
|
|
2058
|
+
threadTs: input.ref.thread_ts_hint,
|
|
2059
|
+
targetTs: input.ref.message_ts,
|
|
2060
|
+
includeReactions: input.includeReactions
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
if (!msg) {
|
|
2064
|
+
try {
|
|
2065
|
+
const rootResp = await client.api("conversations.replies", {
|
|
2066
|
+
channel: input.ref.channel_id,
|
|
2067
|
+
ts: input.ref.message_ts,
|
|
2068
|
+
limit: 1,
|
|
2069
|
+
include_all_metadata: input.includeReactions ? true : undefined
|
|
2070
|
+
});
|
|
2071
|
+
const [root] = asArray2(rootResp.messages);
|
|
2072
|
+
if (isRecord8(root) && getString4(root.ts) === input.ref.message_ts) {
|
|
2073
|
+
msg = root;
|
|
2074
|
+
}
|
|
2075
|
+
} catch {}
|
|
2076
|
+
}
|
|
2077
|
+
if (!msg) {
|
|
2078
|
+
throw new Error("Message not found (no access or wrong URL)");
|
|
2079
|
+
}
|
|
2080
|
+
const files = asArray2(msg.files).map((f) => toSlackFileSummary(f)).filter((f) => f !== null);
|
|
2081
|
+
const enrichedFiles = files.length > 0 ? await enrichFiles(client, files) : undefined;
|
|
2082
|
+
const text = getString4(msg.text) ?? "";
|
|
2083
|
+
const ts = getString4(msg.ts) ?? input.ref.message_ts;
|
|
2084
|
+
const blocks = Array.isArray(msg.blocks) ? msg.blocks : undefined;
|
|
2085
|
+
const attachments = Array.isArray(msg.attachments) ? msg.attachments : undefined;
|
|
2086
|
+
const reactions = Array.isArray(msg.reactions) ? msg.reactions : undefined;
|
|
2087
|
+
return {
|
|
2088
|
+
channel_id: input.ref.channel_id,
|
|
2089
|
+
ts,
|
|
2090
|
+
thread_ts: getString4(msg.thread_ts),
|
|
2091
|
+
reply_count: getNumber(msg.reply_count),
|
|
2092
|
+
user: getString4(msg.user),
|
|
2093
|
+
bot_id: getString4(msg.bot_id),
|
|
2094
|
+
text,
|
|
2095
|
+
markdown: slackMrkdwnToMarkdown(text),
|
|
2096
|
+
blocks,
|
|
2097
|
+
attachments,
|
|
2098
|
+
files: enrichedFiles,
|
|
2099
|
+
reactions
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
async function findMessageInThread(client, input) {
|
|
2103
|
+
let cursor;
|
|
2104
|
+
for (;; ) {
|
|
2105
|
+
const resp = await client.api("conversations.replies", {
|
|
2106
|
+
channel: input.channelId,
|
|
2107
|
+
ts: input.threadTs,
|
|
2108
|
+
limit: 200,
|
|
2109
|
+
cursor,
|
|
2110
|
+
include_all_metadata: input.includeReactions ? true : undefined
|
|
2111
|
+
});
|
|
2112
|
+
const messages = asArray2(resp.messages);
|
|
2113
|
+
const found = messages.find((m) => isRecord8(m) && getString4(m.ts) === input.targetTs);
|
|
2114
|
+
if (found) {
|
|
2115
|
+
return found;
|
|
2116
|
+
}
|
|
2117
|
+
const meta = isRecord8(resp.response_metadata) ? resp.response_metadata : null;
|
|
2118
|
+
const next = meta ? getString4(meta.next_cursor) : undefined;
|
|
2119
|
+
if (!next) {
|
|
2120
|
+
break;
|
|
2121
|
+
}
|
|
2122
|
+
cursor = next;
|
|
2123
|
+
}
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
async function fetchThread(client, input) {
|
|
2127
|
+
const out = [];
|
|
2128
|
+
let cursor;
|
|
2129
|
+
for (;; ) {
|
|
2130
|
+
const resp = await client.api("conversations.replies", {
|
|
2131
|
+
channel: input.channelId,
|
|
2132
|
+
ts: input.threadTs,
|
|
2133
|
+
limit: 200,
|
|
2134
|
+
cursor,
|
|
2135
|
+
include_all_metadata: input.includeReactions ? true : undefined
|
|
2136
|
+
});
|
|
2137
|
+
const messages = asArray2(resp.messages);
|
|
2138
|
+
for (const m of messages) {
|
|
2139
|
+
if (!isRecord8(m)) {
|
|
2140
|
+
continue;
|
|
2141
|
+
}
|
|
2142
|
+
const files = asArray2(m.files).map((f) => toSlackFileSummary(f)).filter((f) => f !== null);
|
|
2143
|
+
const enrichedFiles = files.length > 0 ? await enrichFiles(client, files) : undefined;
|
|
2144
|
+
const text = getString4(m.text) ?? "";
|
|
2145
|
+
out.push({
|
|
2146
|
+
channel_id: input.channelId,
|
|
2147
|
+
ts: getString4(m.ts) ?? "",
|
|
2148
|
+
thread_ts: getString4(m.thread_ts),
|
|
2149
|
+
reply_count: getNumber(m.reply_count),
|
|
2150
|
+
user: getString4(m.user),
|
|
2151
|
+
bot_id: getString4(m.bot_id),
|
|
2152
|
+
text,
|
|
2153
|
+
markdown: slackMrkdwnToMarkdown(text),
|
|
2154
|
+
blocks: Array.isArray(m.blocks) ? m.blocks : undefined,
|
|
2155
|
+
attachments: Array.isArray(m.attachments) ? m.attachments : undefined,
|
|
2156
|
+
files: enrichedFiles,
|
|
2157
|
+
reactions: Array.isArray(m.reactions) ? m.reactions : undefined
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
const meta = isRecord8(resp.response_metadata) ? resp.response_metadata : null;
|
|
2161
|
+
const next = meta ? getString4(meta.next_cursor) : undefined;
|
|
2162
|
+
if (!next) {
|
|
2163
|
+
break;
|
|
2164
|
+
}
|
|
2165
|
+
cursor = next;
|
|
2166
|
+
}
|
|
2167
|
+
out.sort((a, b) => Number.parseFloat(a.ts) - Number.parseFloat(b.ts));
|
|
2168
|
+
return out;
|
|
2169
|
+
}
|
|
2170
|
+
async function enrichFiles(client, files) {
|
|
2171
|
+
const out = [];
|
|
2172
|
+
for (const f of files) {
|
|
2173
|
+
if (f.mode === "snippet" || !f.url_private_download) {
|
|
2174
|
+
try {
|
|
2175
|
+
const info = await client.api("files.info", { file: f.id });
|
|
2176
|
+
const file = isRecord8(info.file) ? info.file : null;
|
|
2177
|
+
out.push({
|
|
2178
|
+
...f,
|
|
2179
|
+
name: f.name ?? getString4(file?.name),
|
|
2180
|
+
title: f.title ?? getString4(file?.title),
|
|
2181
|
+
mimetype: f.mimetype ?? getString4(file?.mimetype),
|
|
2182
|
+
filetype: f.filetype ?? getString4(file?.filetype),
|
|
2183
|
+
mode: f.mode ?? getString4(file?.mode),
|
|
2184
|
+
permalink: f.permalink ?? getString4(file?.permalink),
|
|
2185
|
+
url_private: f.url_private ?? getString4(file?.url_private),
|
|
2186
|
+
url_private_download: f.url_private_download ?? getString4(file?.url_private_download),
|
|
2187
|
+
snippet: {
|
|
2188
|
+
content: getString4(file?.content),
|
|
2189
|
+
language: getString4(file?.filetype)
|
|
2190
|
+
}
|
|
2191
|
+
});
|
|
2192
|
+
continue;
|
|
2193
|
+
} catch {}
|
|
2194
|
+
}
|
|
2195
|
+
out.push(f);
|
|
2196
|
+
}
|
|
2197
|
+
return out;
|
|
2198
|
+
}
|
|
2199
|
+
function isRecord8(value) {
|
|
2200
|
+
return typeof value === "object" && value !== null;
|
|
2201
|
+
}
|
|
2202
|
+
function getString4(value) {
|
|
2203
|
+
return typeof value === "string" ? value : undefined;
|
|
2204
|
+
}
|
|
2205
|
+
function getNumber(value) {
|
|
2206
|
+
return typeof value === "number" ? value : undefined;
|
|
2207
|
+
}
|
|
2208
|
+
function asArray2(value) {
|
|
2209
|
+
return Array.isArray(value) ? value : [];
|
|
2210
|
+
}
|
|
2211
|
+
function toSlackFileSummary(value) {
|
|
2212
|
+
if (!isRecord8(value)) {
|
|
2213
|
+
return null;
|
|
2214
|
+
}
|
|
2215
|
+
const id = getString4(value.id);
|
|
2216
|
+
if (!id) {
|
|
2217
|
+
return null;
|
|
2218
|
+
}
|
|
2219
|
+
return {
|
|
2220
|
+
id,
|
|
2221
|
+
name: getString4(value.name),
|
|
2222
|
+
title: getString4(value.title),
|
|
2223
|
+
mimetype: getString4(value.mimetype),
|
|
2224
|
+
filetype: getString4(value.filetype),
|
|
2225
|
+
mode: getString4(value.mode),
|
|
2226
|
+
permalink: getString4(value.permalink),
|
|
2227
|
+
url_private: getString4(value.url_private),
|
|
2228
|
+
url_private_download: getString4(value.url_private_download),
|
|
2229
|
+
size: getNumber(value.size)
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
// src/slack/url.ts
|
|
2234
|
+
function parseSlackMessageUrl(input) {
|
|
2235
|
+
let url;
|
|
2236
|
+
try {
|
|
2237
|
+
url = new URL(input);
|
|
2238
|
+
} catch {
|
|
2239
|
+
throw new Error(`Invalid URL: ${input}`);
|
|
2240
|
+
}
|
|
2241
|
+
if (!/\.slack\.com$/i.test(url.hostname)) {
|
|
2242
|
+
throw new Error(`Not a Slack workspace URL: ${url.hostname}`);
|
|
2243
|
+
}
|
|
2244
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
2245
|
+
if (parts.length < 3 || parts[0] !== "archives") {
|
|
2246
|
+
throw new Error(`Unsupported Slack URL path: ${url.pathname}`);
|
|
2247
|
+
}
|
|
2248
|
+
const channel_id = parts[1];
|
|
2249
|
+
const messagePart = parts[2];
|
|
2250
|
+
const match = messagePart.match(/^p(\d{7,})$/);
|
|
2251
|
+
if (!match) {
|
|
2252
|
+
throw new Error(`Unsupported Slack message id: ${messagePart}`);
|
|
2253
|
+
}
|
|
2254
|
+
const digits = match[1];
|
|
2255
|
+
if (digits.length <= 6) {
|
|
2256
|
+
throw new Error(`Invalid Slack message id: ${messagePart}`);
|
|
2257
|
+
}
|
|
2258
|
+
const seconds = digits.slice(0, -6);
|
|
2259
|
+
const micros = digits.slice(-6);
|
|
2260
|
+
const message_ts = `${seconds}.${micros}`;
|
|
2261
|
+
const threadTsParam = url.searchParams.get("thread_ts");
|
|
2262
|
+
const thread_ts_hint = threadTsParam && /^\d{6,}\.\d{6}$/.test(threadTsParam) ? threadTsParam : undefined;
|
|
2263
|
+
const hasCid = url.searchParams.has("cid");
|
|
2264
|
+
const possiblyTruncated = Boolean(threadTsParam && !hasCid);
|
|
2265
|
+
const workspace_url = `${url.protocol}//${url.host}`;
|
|
2266
|
+
return { workspace_url, channel_id, message_ts, thread_ts_hint, raw: input, possiblyTruncated };
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
// src/cli/targets.ts
|
|
2270
|
+
function parseMsgTarget(input) {
|
|
2271
|
+
const trimmed = input.trim();
|
|
2272
|
+
if (!trimmed) {
|
|
2273
|
+
throw new Error("Missing target");
|
|
2274
|
+
}
|
|
2275
|
+
try {
|
|
2276
|
+
const ref = parseSlackMessageUrl(trimmed);
|
|
2277
|
+
return { kind: "url", ref };
|
|
2278
|
+
} catch {}
|
|
2279
|
+
if (trimmed.startsWith("#")) {
|
|
2280
|
+
return { kind: "channel", channel: trimmed };
|
|
2281
|
+
}
|
|
2282
|
+
if (isChannelId(trimmed)) {
|
|
2283
|
+
return { kind: "channel", channel: trimmed };
|
|
2284
|
+
}
|
|
2285
|
+
return { kind: "channel", channel: `#${trimmed}` };
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// src/cli/message-actions.ts
|
|
2289
|
+
function isRecord9(value) {
|
|
2290
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2291
|
+
}
|
|
2292
|
+
function asArray3(value) {
|
|
2293
|
+
return Array.isArray(value) ? value : [];
|
|
2294
|
+
}
|
|
2295
|
+
function getNumber2(value) {
|
|
2296
|
+
return typeof value === "number" ? value : undefined;
|
|
2297
|
+
}
|
|
2298
|
+
async function getThreadSummary(client, input) {
|
|
2299
|
+
const replyCount = input.msg.reply_count ?? 0;
|
|
2300
|
+
const rootTs = input.msg.thread_ts ?? (replyCount > 0 ? input.msg.ts : null);
|
|
2301
|
+
if (!rootTs) {
|
|
2302
|
+
return null;
|
|
2303
|
+
}
|
|
2304
|
+
if (!input.msg.thread_ts && replyCount > 0) {
|
|
2305
|
+
return { ts: rootTs, length: 1 + replyCount };
|
|
2306
|
+
}
|
|
2307
|
+
const resp = await client.api("conversations.replies", {
|
|
2308
|
+
channel: input.channelId,
|
|
2309
|
+
ts: rootTs,
|
|
2310
|
+
limit: 1
|
|
2311
|
+
});
|
|
2312
|
+
const [root] = asArray3(isRecord9(resp) ? resp.messages : undefined);
|
|
2313
|
+
const rootReplyCount = isRecord9(root) ? getNumber2(root.reply_count) : undefined;
|
|
2314
|
+
if (rootReplyCount === undefined) {
|
|
2315
|
+
return { ts: rootTs, length: 1 };
|
|
2316
|
+
}
|
|
2317
|
+
return { ts: rootTs, length: 1 + rootReplyCount };
|
|
2318
|
+
}
|
|
2319
|
+
function inferExt(file) {
|
|
2320
|
+
const mt = (file.mimetype || "").toLowerCase();
|
|
2321
|
+
const ft = (file.filetype || "").toLowerCase();
|
|
2322
|
+
if (mt === "image/png" || ft === "png") {
|
|
2323
|
+
return "png";
|
|
2324
|
+
}
|
|
2325
|
+
if (mt === "image/jpeg" || mt === "image/jpg" || ft === "jpg" || ft === "jpeg") {
|
|
2326
|
+
return "jpg";
|
|
2327
|
+
}
|
|
2328
|
+
if (mt === "image/webp" || ft === "webp") {
|
|
2329
|
+
return "webp";
|
|
2330
|
+
}
|
|
2331
|
+
if (mt === "image/gif" || ft === "gif") {
|
|
2332
|
+
return "gif";
|
|
2333
|
+
}
|
|
2334
|
+
if (mt === "text/plain" || ft === "text") {
|
|
2335
|
+
return "txt";
|
|
2336
|
+
}
|
|
2337
|
+
if (mt === "text/markdown" || ft === "markdown" || ft === "md") {
|
|
2338
|
+
return "md";
|
|
2339
|
+
}
|
|
2340
|
+
if (mt === "application/json" || ft === "json") {
|
|
2341
|
+
return "json";
|
|
2342
|
+
}
|
|
2343
|
+
const name = file.name || file.title || "";
|
|
2344
|
+
const m = name.match(/\.([A-Za-z0-9]{1,10})$/);
|
|
2345
|
+
return m ? m[1].toLowerCase() : null;
|
|
2346
|
+
}
|
|
2347
|
+
async function downloadFilesForMessages(input) {
|
|
2348
|
+
const downloadedPaths = {};
|
|
2349
|
+
const downloadsDir = await ensureDownloadsDir();
|
|
2350
|
+
for (const m of input.messages) {
|
|
2351
|
+
for (const f of m.files ?? []) {
|
|
2352
|
+
if (downloadedPaths[f.id]) {
|
|
2353
|
+
continue;
|
|
2354
|
+
}
|
|
2355
|
+
const url = f.url_private_download || f.url_private;
|
|
2356
|
+
if (!url) {
|
|
2357
|
+
continue;
|
|
2358
|
+
}
|
|
2359
|
+
const ext = inferExt(f);
|
|
2360
|
+
const path = await downloadSlackFile({
|
|
2361
|
+
auth: input.auth,
|
|
2362
|
+
url,
|
|
2363
|
+
destDir: downloadsDir,
|
|
2364
|
+
preferredName: `${f.id}${ext ? `.${ext}` : ""}`
|
|
2365
|
+
});
|
|
2366
|
+
downloadedPaths[f.id] = path;
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
return downloadedPaths;
|
|
2370
|
+
}
|
|
2371
|
+
function toThreadListMessage(m) {
|
|
2372
|
+
const { channel_id: _channelId, thread_ts: _threadTs, ...rest } = m;
|
|
2373
|
+
return rest;
|
|
2374
|
+
}
|
|
2375
|
+
function warnIfTruncated(ref) {
|
|
2376
|
+
if (ref.possiblyTruncated) {
|
|
2377
|
+
console.error(`Hint: URL may have been truncated by shell. Quote URLs containing "&":
|
|
2378
|
+
` + ' agent-slack message get "https://...?thread_ts=...&cid=..."');
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
async function handleMessageGet(input) {
|
|
2382
|
+
const target = parseMsgTarget(input.targetInput);
|
|
2383
|
+
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
|
|
2384
|
+
return input.ctx.withAutoRefresh({
|
|
2385
|
+
workspaceUrl: target.kind === "url" ? target.ref.workspace_url : workspaceUrl,
|
|
2386
|
+
work: async () => {
|
|
2387
|
+
if (target.kind === "url") {
|
|
2388
|
+
const { ref: ref2 } = target;
|
|
2389
|
+
warnIfTruncated(ref2);
|
|
2390
|
+
const { client: client2, auth: auth2 } = await input.ctx.getClientForWorkspace(ref2.workspace_url);
|
|
2391
|
+
const includeReactions2 = Boolean(input.options.includeReactions);
|
|
2392
|
+
const msg2 = await fetchMessage(client2, { ref: ref2, includeReactions: includeReactions2 });
|
|
2393
|
+
const thread2 = await getThreadSummary(client2, { channelId: ref2.channel_id, msg: msg2 });
|
|
2394
|
+
const downloadedPaths2 = await downloadFilesForMessages({ auth: auth2, messages: [msg2] });
|
|
2395
|
+
const maxBodyChars2 = Number.parseInt(input.options.maxBodyChars, 10);
|
|
2396
|
+
const message2 = toCompactMessage(msg2, { maxBodyChars: maxBodyChars2, includeReactions: includeReactions2, downloadedPaths: downloadedPaths2 });
|
|
2397
|
+
return pruneEmpty({ message: message2, thread: thread2 });
|
|
2398
|
+
}
|
|
2399
|
+
const ts = input.options.ts?.trim();
|
|
2400
|
+
if (!ts) {
|
|
2401
|
+
throw new Error('When targeting a channel, you must pass --ts "<seconds>.<micros>"');
|
|
2402
|
+
}
|
|
2403
|
+
await input.ctx.assertWorkspaceSpecifiedForChannelNames({
|
|
2404
|
+
workspaceUrl,
|
|
2405
|
+
channels: [target.channel]
|
|
2406
|
+
});
|
|
2407
|
+
const includeReactions = Boolean(input.options.includeReactions);
|
|
2408
|
+
const { client, auth, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
|
|
2409
|
+
const channelId = await resolveChannelId(client, target.channel);
|
|
2410
|
+
const ref = {
|
|
2411
|
+
workspace_url: workspace_url ?? workspaceUrl ?? "",
|
|
2412
|
+
channel_id: channelId,
|
|
2413
|
+
message_ts: ts,
|
|
2414
|
+
thread_ts_hint: input.options.threadTs?.trim() || undefined,
|
|
2415
|
+
raw: input.targetInput
|
|
2416
|
+
};
|
|
2417
|
+
const msg = await fetchMessage(client, { ref, includeReactions });
|
|
2418
|
+
const thread = await getThreadSummary(client, { channelId, msg });
|
|
2419
|
+
const downloadedPaths = await downloadFilesForMessages({ auth, messages: [msg] });
|
|
2420
|
+
const maxBodyChars = Number.parseInt(input.options.maxBodyChars, 10);
|
|
2421
|
+
const message = toCompactMessage(msg, { maxBodyChars, includeReactions, downloadedPaths });
|
|
2422
|
+
return pruneEmpty({ message, thread });
|
|
2423
|
+
}
|
|
2424
|
+
});
|
|
2425
|
+
}
|
|
2426
|
+
async function handleMessageList(input) {
|
|
2427
|
+
const target = parseMsgTarget(input.targetInput);
|
|
2428
|
+
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
|
|
2429
|
+
return input.ctx.withAutoRefresh({
|
|
2430
|
+
workspaceUrl: target.kind === "url" ? target.ref.workspace_url : workspaceUrl,
|
|
2431
|
+
work: async () => {
|
|
2432
|
+
if (target.kind === "url") {
|
|
2433
|
+
const { ref } = target;
|
|
2434
|
+
warnIfTruncated(ref);
|
|
2435
|
+
const { client: client2, auth: auth2 } = await input.ctx.getClientForWorkspace(ref.workspace_url);
|
|
2436
|
+
const includeReactions2 = Boolean(input.options.includeReactions);
|
|
2437
|
+
const msg = await fetchMessage(client2, { ref, includeReactions: includeReactions2 });
|
|
2438
|
+
const rootTs2 = msg.thread_ts ?? msg.ts;
|
|
2439
|
+
const threadMessages2 = await fetchThread(client2, {
|
|
2440
|
+
channelId: ref.channel_id,
|
|
2441
|
+
threadTs: rootTs2,
|
|
2442
|
+
includeReactions: includeReactions2
|
|
2443
|
+
});
|
|
2444
|
+
const downloadedPaths2 = await downloadFilesForMessages({ auth: auth2, messages: threadMessages2 });
|
|
2445
|
+
const maxBodyChars2 = Number.parseInt(input.options.maxBodyChars, 10);
|
|
2446
|
+
return pruneEmpty({
|
|
2447
|
+
messages: threadMessages2.map((m) => toCompactMessage(m, { maxBodyChars: maxBodyChars2, includeReactions: includeReactions2, downloadedPaths: downloadedPaths2 })).map(toThreadListMessage)
|
|
2448
|
+
});
|
|
2449
|
+
}
|
|
2450
|
+
const { client, auth, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
|
|
2451
|
+
await input.ctx.assertWorkspaceSpecifiedForChannelNames({
|
|
2452
|
+
workspaceUrl,
|
|
2453
|
+
channels: [target.channel]
|
|
2454
|
+
});
|
|
2455
|
+
const channelId = await resolveChannelId(client, target.channel);
|
|
2456
|
+
const threadTs = input.options.threadTs?.trim();
|
|
2457
|
+
const ts = input.options.ts?.trim();
|
|
2458
|
+
if (!threadTs && !ts) {
|
|
2459
|
+
throw new Error('When targeting a channel, you must pass --thread-ts "<seconds>.<micros>" (or --ts to resolve a message to its thread)');
|
|
2460
|
+
}
|
|
2461
|
+
const rootTs = threadTs ?? await (async () => {
|
|
2462
|
+
const ref = {
|
|
2463
|
+
workspace_url: workspace_url ?? workspaceUrl ?? "",
|
|
2464
|
+
channel_id: channelId,
|
|
2465
|
+
message_ts: ts,
|
|
2466
|
+
raw: input.targetInput
|
|
2467
|
+
};
|
|
2468
|
+
const includeReactions2 = Boolean(input.options.includeReactions);
|
|
2469
|
+
const msg = await fetchMessage(client, { ref, includeReactions: includeReactions2 });
|
|
2470
|
+
return msg.thread_ts ?? msg.ts;
|
|
2471
|
+
})();
|
|
2472
|
+
const includeReactions = Boolean(input.options.includeReactions);
|
|
2473
|
+
const threadMessages = await fetchThread(client, {
|
|
2474
|
+
channelId,
|
|
2475
|
+
threadTs: rootTs,
|
|
2476
|
+
includeReactions
|
|
2477
|
+
});
|
|
2478
|
+
const downloadedPaths = await downloadFilesForMessages({ auth, messages: threadMessages });
|
|
2479
|
+
const maxBodyChars = Number.parseInt(input.options.maxBodyChars, 10);
|
|
2480
|
+
return pruneEmpty({
|
|
2481
|
+
messages: threadMessages.map((m) => toCompactMessage(m, { maxBodyChars, includeReactions, downloadedPaths })).map(toThreadListMessage)
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2486
|
+
async function sendMessage(input) {
|
|
2487
|
+
const target = parseMsgTarget(String(input.targetInput));
|
|
2488
|
+
if (target.kind === "url") {
|
|
2489
|
+
const { ref } = target;
|
|
2490
|
+
warnIfTruncated(ref);
|
|
2491
|
+
await input.ctx.withAutoRefresh({
|
|
2492
|
+
workspaceUrl: ref.workspace_url,
|
|
2493
|
+
work: async () => {
|
|
2494
|
+
const { client } = await input.ctx.getClientForWorkspace(ref.workspace_url);
|
|
2495
|
+
const msg = await fetchMessage(client, { ref });
|
|
2496
|
+
const threadTs = msg.thread_ts ?? msg.ts;
|
|
2497
|
+
await client.api("chat.postMessage", {
|
|
2498
|
+
channel: ref.channel_id,
|
|
2499
|
+
text: input.text,
|
|
2500
|
+
thread_ts: threadTs
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
});
|
|
2504
|
+
return { ok: true };
|
|
2505
|
+
}
|
|
2506
|
+
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
|
|
2507
|
+
await input.ctx.assertWorkspaceSpecifiedForChannelNames({
|
|
2508
|
+
workspaceUrl,
|
|
2509
|
+
channels: [String(target.channel)]
|
|
2510
|
+
});
|
|
2511
|
+
await input.ctx.withAutoRefresh({
|
|
2512
|
+
workspaceUrl,
|
|
2513
|
+
work: async () => {
|
|
2514
|
+
const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
|
|
2515
|
+
const channelId = await resolveChannelId(client, String(target.channel));
|
|
2516
|
+
await client.api("chat.postMessage", {
|
|
2517
|
+
channel: channelId,
|
|
2518
|
+
text: input.text,
|
|
2519
|
+
thread_ts: input.options.threadTs ? String(input.options.threadTs) : undefined
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
});
|
|
2523
|
+
return { ok: true };
|
|
2524
|
+
}
|
|
2525
|
+
async function reactOnTarget(input) {
|
|
2526
|
+
const target = parseMsgTarget(input.targetInput);
|
|
2527
|
+
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options?.workspace);
|
|
2528
|
+
await input.ctx.withAutoRefresh({
|
|
2529
|
+
workspaceUrl: target.kind === "url" ? target.ref.workspace_url : workspaceUrl,
|
|
2530
|
+
work: async () => {
|
|
2531
|
+
if (target.kind === "url") {
|
|
2532
|
+
const { ref } = target;
|
|
2533
|
+
warnIfTruncated(ref);
|
|
2534
|
+
const { client: client2 } = await input.ctx.getClientForWorkspace(ref.workspace_url);
|
|
2535
|
+
const name2 = normalizeSlackReactionName(input.emoji);
|
|
2536
|
+
await client2.api(`reactions.${input.action}`, {
|
|
2537
|
+
channel: ref.channel_id,
|
|
2538
|
+
timestamp: ref.message_ts,
|
|
2539
|
+
name: name2
|
|
2540
|
+
});
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
const ts = input.options?.ts?.trim();
|
|
2544
|
+
if (!ts) {
|
|
2545
|
+
throw new Error('When targeting a channel, you must pass --ts "<seconds>.<micros>"');
|
|
2546
|
+
}
|
|
2547
|
+
await input.ctx.assertWorkspaceSpecifiedForChannelNames({
|
|
2548
|
+
workspaceUrl,
|
|
2549
|
+
channels: [target.channel]
|
|
2550
|
+
});
|
|
2551
|
+
const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
|
|
2552
|
+
const channelId = await resolveChannelId(client, target.channel);
|
|
2553
|
+
const name = normalizeSlackReactionName(input.emoji);
|
|
2554
|
+
await client.api(`reactions.${input.action}`, {
|
|
2555
|
+
channel: channelId,
|
|
2556
|
+
timestamp: ts,
|
|
2557
|
+
name
|
|
2558
|
+
});
|
|
2559
|
+
}
|
|
2560
|
+
});
|
|
2561
|
+
return { ok: true };
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// src/cli/message-command.ts
|
|
2565
|
+
function registerMessageCommand(input) {
|
|
2566
|
+
const messageCmd = input.program.command("message").description("Read/write Slack messages (token-efficient JSON)");
|
|
2567
|
+
messageCmd.command("get", { isDefault: true }).description("Fetch a single Slack message (with thread summary if any)").argument("<target>", "Slack message URL, #channel, or channel ID").option("--workspace <url>", "Workspace URL (needed when using #channel/channel id and you have multiple workspaces)").option("--ts <ts>", "Message ts (required when using #channel/channel id)").option("--thread-ts <ts>", "Thread root ts hint (useful for thread permalinks)").option("--max-body-chars <n>", "Max content characters to include (default 8000, -1 for unlimited)", "8000").option("--include-reactions", "Include reactions + reacting users").action(async (...args) => {
|
|
2568
|
+
const [targetInput, options] = args;
|
|
2569
|
+
try {
|
|
2570
|
+
const payload = await handleMessageGet({ ctx: input.ctx, targetInput, options });
|
|
2571
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
2572
|
+
} catch (err) {
|
|
2573
|
+
console.error(input.ctx.errorMessage(err));
|
|
2574
|
+
process.exitCode = 1;
|
|
2575
|
+
}
|
|
2576
|
+
});
|
|
2577
|
+
messageCmd.command("list").description("Fetch the full thread for a Slack message URL").argument("<target>", "Slack message URL, #channel, or channel ID").option("--workspace <url>", "Workspace URL (needed when using #channel/channel id and you have multiple workspaces)").option("--thread-ts <ts>", "Thread root ts (required when using #channel/channel id unless you pass --ts)").option("--ts <ts>", "Message ts (optional: resolve message to its thread)").option("--max-body-chars <n>", "Max content characters to include (default 8000, -1 for unlimited)", "8000").option("--include-reactions", "Include reactions + reacting users").action(async (...args) => {
|
|
2578
|
+
const [targetInput, options] = args;
|
|
2579
|
+
try {
|
|
2580
|
+
const payload = await handleMessageList({ ctx: input.ctx, targetInput, options });
|
|
2581
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
2582
|
+
} catch (err) {
|
|
2583
|
+
console.error(input.ctx.errorMessage(err));
|
|
2584
|
+
process.exitCode = 1;
|
|
2585
|
+
}
|
|
2586
|
+
});
|
|
2587
|
+
messageCmd.command("send").description("Send a message (optionally into a thread)").argument("<target>", "Slack message URL, #name/name, or channel id").argument("<text>", "Message text to post").option("--workspace <url>", "Workspace URL (needed when using #channel/channel id and you have multiple workspaces)").option("--thread-ts <ts>", "Thread root ts to post into (optional)").action(async (...args) => {
|
|
2588
|
+
const [targetInput, text, options] = args;
|
|
2589
|
+
try {
|
|
2590
|
+
const payload = await sendMessage({
|
|
2591
|
+
ctx: input.ctx,
|
|
2592
|
+
targetInput,
|
|
2593
|
+
text,
|
|
2594
|
+
options
|
|
2595
|
+
});
|
|
2596
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
2597
|
+
} catch (err) {
|
|
2598
|
+
console.error(input.ctx.errorMessage(err));
|
|
2599
|
+
process.exitCode = 1;
|
|
2600
|
+
}
|
|
2601
|
+
});
|
|
2602
|
+
const reactCmd = messageCmd.command("react").description("Add or remove reactions");
|
|
2603
|
+
reactCmd.command("add").description("Add a reaction to a message").argument("<target>", "Slack message URL, #channel, or channel ID").argument("<emoji>", "Emoji to react with (:rocket:, rocket, or \uD83D\uDE80)").option("--workspace <url>", "Workspace URL (needed when using #channel/channel id and you have multiple workspaces)").option("--ts <ts>", "Message ts (required when using #channel/channel id)").action(async (...args) => {
|
|
2604
|
+
const [targetInput, emoji2, options] = args;
|
|
2605
|
+
try {
|
|
2606
|
+
const payload = await reactOnTarget({
|
|
2607
|
+
ctx: input.ctx,
|
|
2608
|
+
action: "add",
|
|
2609
|
+
targetInput,
|
|
2610
|
+
emoji: emoji2,
|
|
2611
|
+
options
|
|
2612
|
+
});
|
|
2613
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
2614
|
+
} catch (err) {
|
|
2615
|
+
console.error(input.ctx.errorMessage(err));
|
|
2616
|
+
process.exitCode = 1;
|
|
2617
|
+
}
|
|
2618
|
+
});
|
|
2619
|
+
reactCmd.command("remove").description("Remove a reaction from a message").argument("<target>", "Slack message URL, #channel, or channel ID").argument("<emoji>", "Emoji to remove (:rocket:, rocket, or \uD83D\uDE80)").option("--workspace <url>", "Workspace URL (needed when using #channel/channel id and you have multiple workspaces)").option("--ts <ts>", "Message ts (required when using #channel/channel id)").action(async (...args) => {
|
|
2620
|
+
const [targetInput, emoji2, options] = args;
|
|
2621
|
+
try {
|
|
2622
|
+
const payload = await reactOnTarget({
|
|
2623
|
+
ctx: input.ctx,
|
|
2624
|
+
action: "remove",
|
|
2625
|
+
targetInput,
|
|
2626
|
+
emoji: emoji2,
|
|
2627
|
+
options
|
|
2628
|
+
});
|
|
2629
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
2630
|
+
} catch (err) {
|
|
2631
|
+
console.error(input.ctx.errorMessage(err));
|
|
2632
|
+
process.exitCode = 1;
|
|
2633
|
+
}
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
// src/slack/search-guards.ts
|
|
2638
|
+
function isRecord10(value) {
|
|
2639
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2640
|
+
}
|
|
2641
|
+
function asArray4(value) {
|
|
2642
|
+
return Array.isArray(value) ? value : [];
|
|
2643
|
+
}
|
|
2644
|
+
function getString5(value) {
|
|
2645
|
+
return typeof value === "string" ? value : undefined;
|
|
2646
|
+
}
|
|
2647
|
+
function getNumber3(value) {
|
|
2648
|
+
return typeof value === "number" ? value : undefined;
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
// src/slack/search-query.ts
|
|
2652
|
+
async function buildSlackSearchQuery(client, input) {
|
|
2653
|
+
const parts = [];
|
|
2654
|
+
const base = input.query.trim();
|
|
2655
|
+
if (base) {
|
|
2656
|
+
parts.push(base);
|
|
2657
|
+
}
|
|
2658
|
+
if (input.after) {
|
|
2659
|
+
parts.push(`after:${validateDate(input.after)}`);
|
|
2660
|
+
}
|
|
2661
|
+
if (input.before) {
|
|
2662
|
+
parts.push(`before:${validateDate(input.before)}`);
|
|
2663
|
+
}
|
|
2664
|
+
if (input.user) {
|
|
2665
|
+
const token = await userTokenForSearch(client, input.user);
|
|
2666
|
+
if (token) {
|
|
2667
|
+
parts.push(token);
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
if (input.channels && input.channels.length > 0) {
|
|
2671
|
+
for (const ch of input.channels) {
|
|
2672
|
+
const inToken = await channelTokenForSearch(client, ch);
|
|
2673
|
+
if (inToken) {
|
|
2674
|
+
parts.push(inToken);
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
return parts.join(" ");
|
|
2679
|
+
}
|
|
2680
|
+
function validateDate(s) {
|
|
2681
|
+
const v = s.trim();
|
|
2682
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) {
|
|
2683
|
+
throw new Error(`Invalid date: ${s} (expected YYYY-MM-DD)`);
|
|
2684
|
+
}
|
|
2685
|
+
return v;
|
|
2686
|
+
}
|
|
2687
|
+
function dateToUnixSeconds(date, edge) {
|
|
2688
|
+
const d = validateDate(date);
|
|
2689
|
+
const iso = edge === "start" ? `${d}T00:00:00.000Z` : `${d}T23:59:59.999Z`;
|
|
2690
|
+
return Math.floor(Date.parse(iso) / 1000);
|
|
2691
|
+
}
|
|
2692
|
+
async function userTokenForSearch(client, user) {
|
|
2693
|
+
const trimmed = user.trim();
|
|
2694
|
+
if (!trimmed) {
|
|
2695
|
+
return null;
|
|
2696
|
+
}
|
|
2697
|
+
if (trimmed.startsWith("@")) {
|
|
2698
|
+
return `from:@${trimmed.slice(1)}`;
|
|
2699
|
+
}
|
|
2700
|
+
if (/^U[A-Z0-9]{8,}$/.test(trimmed)) {
|
|
2701
|
+
try {
|
|
2702
|
+
const info = await client.api("users.info", { user: trimmed });
|
|
2703
|
+
const infoUser = isRecord10(info) ? info.user : null;
|
|
2704
|
+
const name = isRecord10(infoUser) ? (getString5(infoUser.name) ?? "").trim() : "";
|
|
2705
|
+
return name ? `from:@${name}` : null;
|
|
2706
|
+
} catch {
|
|
2707
|
+
return null;
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
return `from:@${trimmed}`;
|
|
2711
|
+
}
|
|
2712
|
+
async function channelTokenForSearch(client, channel) {
|
|
2713
|
+
const normalized = normalizeChannelInput(channel);
|
|
2714
|
+
if (normalized.kind === "name") {
|
|
2715
|
+
const name = normalized.value.trim();
|
|
2716
|
+
if (!name) {
|
|
2717
|
+
return null;
|
|
2718
|
+
}
|
|
2719
|
+
return `in:#${name}`;
|
|
2720
|
+
}
|
|
2721
|
+
try {
|
|
2722
|
+
const info = await client.api("conversations.info", {
|
|
2723
|
+
channel: normalized.value
|
|
2724
|
+
});
|
|
2725
|
+
const channelInfo = isRecord10(info) ? info.channel : null;
|
|
2726
|
+
const name = isRecord10(channelInfo) ? (getString5(channelInfo.name) ?? "").trim() : "";
|
|
2727
|
+
if (name) {
|
|
2728
|
+
return `in:#${name}`;
|
|
2729
|
+
}
|
|
2730
|
+
} catch {}
|
|
2731
|
+
return null;
|
|
2732
|
+
}
|
|
2733
|
+
async function resolveUserId(client, input) {
|
|
2734
|
+
const trimmed = input.trim();
|
|
2735
|
+
if (!trimmed) {
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
if (/^U[A-Z0-9]{8,}$/.test(trimmed)) {
|
|
2739
|
+
return trimmed;
|
|
2740
|
+
}
|
|
2741
|
+
const name = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
2742
|
+
let cursor;
|
|
2743
|
+
for (;; ) {
|
|
2744
|
+
const resp = await client.api("users.list", { limit: 200, cursor });
|
|
2745
|
+
const members = isRecord10(resp) ? asArray4(resp.members).filter(isRecord10) : [];
|
|
2746
|
+
const found = members.find((m) => {
|
|
2747
|
+
const mName = getString5(m.name);
|
|
2748
|
+
const profile = isRecord10(m.profile) ? m.profile : null;
|
|
2749
|
+
const display = profile ? getString5(profile.display_name) : undefined;
|
|
2750
|
+
return mName === name || display === name;
|
|
2751
|
+
});
|
|
2752
|
+
const foundId = found ? getString5(found.id) : undefined;
|
|
2753
|
+
if (foundId) {
|
|
2754
|
+
return foundId;
|
|
2755
|
+
}
|
|
2756
|
+
const meta = isRecord10(resp) ? resp.response_metadata : null;
|
|
2757
|
+
const next = isRecord10(meta) ? getString5(meta.next_cursor) : undefined;
|
|
2758
|
+
if (!next) {
|
|
2759
|
+
break;
|
|
2760
|
+
}
|
|
2761
|
+
cursor = next;
|
|
2762
|
+
}
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
// src/slack/search-raw.ts
|
|
2767
|
+
async function searchMessagesRaw(client, input) {
|
|
2768
|
+
const pageSize = Math.min(Math.max(input.limit, 1), 100);
|
|
2769
|
+
const out = [];
|
|
2770
|
+
let page = 1;
|
|
2771
|
+
let pages = 1;
|
|
2772
|
+
for (;; ) {
|
|
2773
|
+
const resp = await client.api("search.messages", {
|
|
2774
|
+
query: input.query,
|
|
2775
|
+
count: pageSize,
|
|
2776
|
+
page,
|
|
2777
|
+
highlight: false,
|
|
2778
|
+
sort: "timestamp",
|
|
2779
|
+
sort_dir: "desc"
|
|
2780
|
+
});
|
|
2781
|
+
const messages = isRecord10(resp) ? resp.messages : null;
|
|
2782
|
+
const matches = isRecord10(messages) ? asArray4(messages.matches).filter(isRecord10) : [];
|
|
2783
|
+
out.push(...matches);
|
|
2784
|
+
const paging = isRecord10(messages) ? messages.paging ?? messages.pagination : null;
|
|
2785
|
+
const totalPages = Number(isRecord10(paging) ? paging.pages ?? 1 : 1);
|
|
2786
|
+
if (Number.isFinite(totalPages) && totalPages > 0) {
|
|
2787
|
+
pages = totalPages;
|
|
2788
|
+
}
|
|
2789
|
+
if (out.length >= input.limit) {
|
|
2790
|
+
break;
|
|
2791
|
+
}
|
|
2792
|
+
if (matches.length === 0) {
|
|
2793
|
+
break;
|
|
2794
|
+
}
|
|
2795
|
+
if (page >= pages) {
|
|
2796
|
+
break;
|
|
2797
|
+
}
|
|
2798
|
+
page++;
|
|
2799
|
+
}
|
|
2800
|
+
return out.slice(0, input.limit);
|
|
2801
|
+
}
|
|
2802
|
+
async function searchFilesRaw(client, input) {
|
|
2803
|
+
const pageSize = Math.min(Math.max(input.limit, 1), 100);
|
|
2804
|
+
const out = [];
|
|
2805
|
+
let page = 1;
|
|
2806
|
+
let pages = 1;
|
|
2807
|
+
for (;; ) {
|
|
2808
|
+
const resp = await client.api("search.files", {
|
|
2809
|
+
query: input.query,
|
|
2810
|
+
count: pageSize,
|
|
2811
|
+
page,
|
|
2812
|
+
highlight: false,
|
|
2813
|
+
sort: "timestamp",
|
|
2814
|
+
sort_dir: "desc"
|
|
2815
|
+
});
|
|
2816
|
+
const files = isRecord10(resp) ? resp.files : null;
|
|
2817
|
+
const matches = isRecord10(files) ? asArray4(files.matches).filter(isRecord10) : [];
|
|
2818
|
+
out.push(...matches);
|
|
2819
|
+
const paging = isRecord10(files) ? files.paging ?? files.pagination : null;
|
|
2820
|
+
const totalPages = Number(isRecord10(paging) ? paging.pages ?? 1 : 1);
|
|
2821
|
+
if (Number.isFinite(totalPages) && totalPages > 0) {
|
|
2822
|
+
pages = totalPages;
|
|
2823
|
+
}
|
|
2824
|
+
if (out.length >= input.limit) {
|
|
2825
|
+
break;
|
|
2826
|
+
}
|
|
2827
|
+
if (matches.length === 0) {
|
|
2828
|
+
break;
|
|
2829
|
+
}
|
|
2830
|
+
if (page >= pages) {
|
|
2831
|
+
break;
|
|
2832
|
+
}
|
|
2833
|
+
page++;
|
|
2834
|
+
}
|
|
2835
|
+
return out.slice(0, input.limit);
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
// src/slack/search-file-ext.ts
|
|
2839
|
+
function inferExt2(file) {
|
|
2840
|
+
const mt = (file.mimetype || "").toLowerCase();
|
|
2841
|
+
const ft = (file.filetype || "").toLowerCase();
|
|
2842
|
+
if (mt === "image/png" || ft === "png") {
|
|
2843
|
+
return "png";
|
|
2844
|
+
}
|
|
2845
|
+
if (mt === "image/jpeg" || mt === "image/jpg" || ft === "jpg" || ft === "jpeg") {
|
|
2846
|
+
return "jpg";
|
|
2847
|
+
}
|
|
2848
|
+
if (mt === "image/webp" || ft === "webp") {
|
|
2849
|
+
return "webp";
|
|
2850
|
+
}
|
|
2851
|
+
if (mt === "image/gif" || ft === "gif") {
|
|
2852
|
+
return "gif";
|
|
2853
|
+
}
|
|
2854
|
+
if (mt === "text/plain" || ft === "text") {
|
|
2855
|
+
return "txt";
|
|
2856
|
+
}
|
|
2857
|
+
if (mt === "text/markdown" || ft === "markdown" || ft === "md") {
|
|
2858
|
+
return "md";
|
|
2859
|
+
}
|
|
2860
|
+
if (mt === "application/json" || ft === "json") {
|
|
2861
|
+
return "json";
|
|
2862
|
+
}
|
|
2863
|
+
const name = file.name || file.title || "";
|
|
2864
|
+
const m = name.match(/\.([A-Za-z0-9]{1,10})$/);
|
|
2865
|
+
return m ? m[1].toLowerCase() : null;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
// src/slack/search-files.ts
|
|
2869
|
+
async function searchFilesViaSearchApi(client, input) {
|
|
2870
|
+
const matches = input.rawMatches;
|
|
2871
|
+
if (matches.length === 0) {
|
|
2872
|
+
return [];
|
|
2873
|
+
}
|
|
2874
|
+
const downloadsDir = await ensureDownloadsDir();
|
|
2875
|
+
const out = [];
|
|
2876
|
+
for (const f of matches) {
|
|
2877
|
+
const mode = getString5(f.mode);
|
|
2878
|
+
const mimetype = getString5(f.mimetype);
|
|
2879
|
+
if (!passesFileContentTypeFilter({ mode, mimetype }, input.contentType)) {
|
|
2880
|
+
continue;
|
|
2881
|
+
}
|
|
2882
|
+
const url = getString5(f.url_private_download) ?? getString5(f.url_private);
|
|
2883
|
+
if (!url) {
|
|
2884
|
+
continue;
|
|
2885
|
+
}
|
|
2886
|
+
const ext = inferExt2({
|
|
2887
|
+
mimetype,
|
|
2888
|
+
filetype: getString5(f.filetype),
|
|
2889
|
+
name: getString5(f.name),
|
|
2890
|
+
title: getString5(f.title)
|
|
2891
|
+
});
|
|
2892
|
+
const id = getString5(f.id);
|
|
2893
|
+
if (!id) {
|
|
2894
|
+
continue;
|
|
2895
|
+
}
|
|
2896
|
+
const path = await downloadSlackFile({
|
|
2897
|
+
auth: input.auth,
|
|
2898
|
+
url,
|
|
2899
|
+
destDir: downloadsDir,
|
|
2900
|
+
preferredName: `${id}${ext ? `.${ext}` : ""}`
|
|
2901
|
+
});
|
|
2902
|
+
const title = (getString5(f.title) || getString5(f.name) || "").trim();
|
|
2903
|
+
out.push({
|
|
2904
|
+
title: title || undefined,
|
|
2905
|
+
mimetype,
|
|
2906
|
+
mode,
|
|
2907
|
+
path
|
|
2908
|
+
});
|
|
2909
|
+
if (out.length >= input.limit) {
|
|
2910
|
+
break;
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
return out;
|
|
2914
|
+
}
|
|
2915
|
+
async function searchFilesInChannelsFallback(client, input) {
|
|
2916
|
+
const channelIds = await Promise.all(input.channels.map((c) => resolveChannelId(client, c)));
|
|
2917
|
+
const userId = input.user ? await resolveUserId(client, input.user) : undefined;
|
|
2918
|
+
const queryLower = input.query.trim().toLowerCase();
|
|
2919
|
+
const ts_from = input.after ? dateToUnixSeconds(input.after, "start") : undefined;
|
|
2920
|
+
const ts_to = input.before ? dateToUnixSeconds(input.before, "end") : undefined;
|
|
2921
|
+
const downloadsDir = await ensureDownloadsDir();
|
|
2922
|
+
const out = [];
|
|
2923
|
+
for (const channelId of channelIds) {
|
|
2924
|
+
let page = 1;
|
|
2925
|
+
for (;; ) {
|
|
2926
|
+
const resp = await client.api("files.list", {
|
|
2927
|
+
channel: channelId,
|
|
2928
|
+
user: userId,
|
|
2929
|
+
ts_from,
|
|
2930
|
+
ts_to,
|
|
2931
|
+
count: 100,
|
|
2932
|
+
page
|
|
2933
|
+
});
|
|
2934
|
+
const files = isRecord10(resp) ? asArray4(resp.files).filter(isRecord10) : [];
|
|
2935
|
+
if (files.length === 0) {
|
|
2936
|
+
break;
|
|
2937
|
+
}
|
|
2938
|
+
for (const f of files) {
|
|
2939
|
+
const mode = getString5(f.mode);
|
|
2940
|
+
const mimetype = getString5(f.mimetype);
|
|
2941
|
+
if (!passesFileContentTypeFilter({ mode, mimetype }, input.contentType)) {
|
|
2942
|
+
continue;
|
|
2943
|
+
}
|
|
2944
|
+
const title = (getString5(f.title) || getString5(f.name) || "").trim();
|
|
2945
|
+
if (queryLower && !title.toLowerCase().includes(queryLower)) {
|
|
2946
|
+
continue;
|
|
2947
|
+
}
|
|
2948
|
+
const url = getString5(f.url_private_download) ?? getString5(f.url_private);
|
|
2949
|
+
if (!url) {
|
|
2950
|
+
continue;
|
|
2951
|
+
}
|
|
2952
|
+
const ext = inferExt2({
|
|
2953
|
+
mimetype,
|
|
2954
|
+
filetype: getString5(f.filetype),
|
|
2955
|
+
name: getString5(f.name),
|
|
2956
|
+
title: getString5(f.title)
|
|
2957
|
+
});
|
|
2958
|
+
const id = getString5(f.id);
|
|
2959
|
+
if (!id) {
|
|
2960
|
+
continue;
|
|
2961
|
+
}
|
|
2962
|
+
const path = await downloadSlackFile({
|
|
2963
|
+
auth: input.auth,
|
|
2964
|
+
url,
|
|
2965
|
+
destDir: downloadsDir,
|
|
2966
|
+
preferredName: `${id}${ext ? `.${ext}` : ""}`
|
|
2967
|
+
});
|
|
2968
|
+
out.push({
|
|
2969
|
+
title: title || undefined,
|
|
2970
|
+
mimetype,
|
|
2971
|
+
mode,
|
|
2972
|
+
path
|
|
2973
|
+
});
|
|
2974
|
+
if (out.length >= input.limit) {
|
|
2975
|
+
return out;
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
const paging = isRecord10(resp) ? isRecord10(resp.paging) ? resp.paging : resp.pagination : null;
|
|
2979
|
+
const pages = Number(isRecord10(paging) ? paging.pages ?? paging.page_count : undefined);
|
|
2980
|
+
if (Number.isFinite(pages) && page >= pages) {
|
|
2981
|
+
break;
|
|
2982
|
+
}
|
|
2983
|
+
page++;
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
return out;
|
|
2987
|
+
}
|
|
2988
|
+
function passesFileContentTypeFilter(f, contentType) {
|
|
2989
|
+
if (contentType === "any") {
|
|
2990
|
+
return true;
|
|
2991
|
+
}
|
|
2992
|
+
if (contentType === "file") {
|
|
2993
|
+
return true;
|
|
2994
|
+
}
|
|
2995
|
+
if (contentType === "snippet") {
|
|
2996
|
+
return f.mode === "snippet";
|
|
2997
|
+
}
|
|
2998
|
+
if (contentType === "image") {
|
|
2999
|
+
return String(f.mimetype ?? "").toLowerCase().startsWith("image/");
|
|
3000
|
+
}
|
|
3001
|
+
if (contentType === "text") {
|
|
3002
|
+
return String(f.mimetype ?? "") === "text/plain";
|
|
3003
|
+
}
|
|
3004
|
+
return true;
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
// src/slack/search-messages.ts
|
|
3008
|
+
async function searchMessagesViaSearchApi(client, input) {
|
|
3009
|
+
const matches = input.rawMatches;
|
|
3010
|
+
if (matches.length === 0) {
|
|
3011
|
+
return [];
|
|
3012
|
+
}
|
|
3013
|
+
const messageRefs = [];
|
|
3014
|
+
for (const m of matches) {
|
|
3015
|
+
const ts = getString5(m.ts)?.trim() ?? "";
|
|
3016
|
+
if (!ts) {
|
|
3017
|
+
continue;
|
|
3018
|
+
}
|
|
3019
|
+
const channelValue = isRecord10(m.channel) ? m.channel : null;
|
|
3020
|
+
const channelId = channelValue && getString5(channelValue.id) ? getString5(channelValue.id) : channelValue && getString5(channelValue.name) ? await resolveChannelId(client, `#${getString5(channelValue.name)}`) : "";
|
|
3021
|
+
if (!channelId) {
|
|
3022
|
+
continue;
|
|
3023
|
+
}
|
|
3024
|
+
messageRefs.push({
|
|
3025
|
+
channel_id: channelId,
|
|
3026
|
+
message_ts: ts,
|
|
3027
|
+
permalink: getString5(m.permalink)
|
|
3028
|
+
});
|
|
3029
|
+
if (messageRefs.length >= input.limit) {
|
|
3030
|
+
break;
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
const downloadedPaths = {};
|
|
3034
|
+
const downloadsDir = input.download ? await ensureDownloadsDir() : null;
|
|
3035
|
+
const out = [];
|
|
3036
|
+
for (const ref of messageRefs) {
|
|
3037
|
+
let full = null;
|
|
3038
|
+
try {
|
|
3039
|
+
const parsed = ref.permalink && typeof ref.permalink === "string" ? (() => {
|
|
3040
|
+
try {
|
|
3041
|
+
return parseSlackMessageUrl(ref.permalink);
|
|
3042
|
+
} catch {
|
|
3043
|
+
return null;
|
|
3044
|
+
}
|
|
3045
|
+
})() : null;
|
|
3046
|
+
full = await fetchMessage(client, {
|
|
3047
|
+
ref: {
|
|
3048
|
+
workspace_url: parsed?.workspace_url ?? input.workspace_url ?? "",
|
|
3049
|
+
channel_id: ref.channel_id,
|
|
3050
|
+
message_ts: ref.message_ts,
|
|
3051
|
+
thread_ts_hint: parsed?.thread_ts_hint,
|
|
3052
|
+
raw: parsed?.raw ?? ref.permalink ?? `${ref.channel_id}:${ref.message_ts}`
|
|
3053
|
+
}
|
|
3054
|
+
});
|
|
3055
|
+
} catch {
|
|
3056
|
+
continue;
|
|
3057
|
+
}
|
|
3058
|
+
if (downloadsDir) {
|
|
3059
|
+
await downloadFilesForMessage({
|
|
3060
|
+
auth: input.auth,
|
|
3061
|
+
downloadsDir,
|
|
3062
|
+
message: full,
|
|
3063
|
+
downloadedPaths
|
|
3064
|
+
});
|
|
3065
|
+
}
|
|
3066
|
+
const compact = toCompactMessage(full, {
|
|
3067
|
+
maxBodyChars: input.maxContentChars,
|
|
3068
|
+
downloadedPaths
|
|
3069
|
+
});
|
|
3070
|
+
if (!passesContentTypeFilter(compact, input.contentType)) {
|
|
3071
|
+
continue;
|
|
3072
|
+
}
|
|
3073
|
+
out.push(stripThreadListFields(compact));
|
|
3074
|
+
if (out.length >= input.limit) {
|
|
3075
|
+
break;
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
return out;
|
|
3079
|
+
}
|
|
3080
|
+
async function searchMessagesInChannelsFallback(client, input) {
|
|
3081
|
+
const channelIds = await Promise.all(input.channels.map((c) => resolveChannelId(client, c)));
|
|
3082
|
+
const queryLower = input.query.trim().toLowerCase();
|
|
3083
|
+
const userId = input.user ? await resolveUserId(client, input.user) : undefined;
|
|
3084
|
+
const afterSec = input.after ? dateToUnixSeconds(input.after, "start") : null;
|
|
3085
|
+
const beforeSec = input.before ? dateToUnixSeconds(input.before, "end") : null;
|
|
3086
|
+
const downloadsDir = input.download ? await ensureDownloadsDir() : null;
|
|
3087
|
+
const downloadedPaths = {};
|
|
3088
|
+
const results = [];
|
|
3089
|
+
for (const channelId of channelIds) {
|
|
3090
|
+
let cursorLatest;
|
|
3091
|
+
for (;; ) {
|
|
3092
|
+
const resp = await client.api("conversations.history", {
|
|
3093
|
+
channel: channelId,
|
|
3094
|
+
limit: 200,
|
|
3095
|
+
latest: cursorLatest
|
|
3096
|
+
});
|
|
3097
|
+
const messages = isRecord10(resp) ? asArray4(resp.messages).filter(isRecord10) : [];
|
|
3098
|
+
if (messages.length === 0) {
|
|
3099
|
+
break;
|
|
3100
|
+
}
|
|
3101
|
+
for (const m of messages) {
|
|
3102
|
+
const summary = messageSummaryFromApiMessage(channelId, m);
|
|
3103
|
+
const tsNum = Number.parseFloat(summary.ts);
|
|
3104
|
+
if (Number.isFinite(tsNum)) {
|
|
3105
|
+
if (beforeSec !== null && tsNum > beforeSec) {
|
|
3106
|
+
continue;
|
|
3107
|
+
}
|
|
3108
|
+
if (afterSec !== null && tsNum < afterSec) {
|
|
3109
|
+
cursorLatest = undefined;
|
|
3110
|
+
break;
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
if (userId && summary.user !== userId) {
|
|
3114
|
+
continue;
|
|
3115
|
+
}
|
|
3116
|
+
const content = renderSlackMessageContent(summary);
|
|
3117
|
+
if (queryLower && !content.toLowerCase().includes(queryLower)) {
|
|
3118
|
+
continue;
|
|
3119
|
+
}
|
|
3120
|
+
if (downloadsDir) {
|
|
3121
|
+
await downloadFilesForMessage({
|
|
3122
|
+
auth: input.auth,
|
|
3123
|
+
downloadsDir,
|
|
3124
|
+
message: summary,
|
|
3125
|
+
downloadedPaths
|
|
3126
|
+
});
|
|
3127
|
+
}
|
|
3128
|
+
const compact = toCompactMessage(summary, {
|
|
3129
|
+
maxBodyChars: input.maxContentChars,
|
|
3130
|
+
downloadedPaths
|
|
3131
|
+
});
|
|
3132
|
+
if (!passesContentTypeFilter(compact, input.contentType)) {
|
|
3133
|
+
continue;
|
|
3134
|
+
}
|
|
3135
|
+
results.push(stripThreadListFields(compact));
|
|
3136
|
+
if (results.length >= input.limit) {
|
|
3137
|
+
return results;
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
if (!cursorLatest) {
|
|
3141
|
+
break;
|
|
3142
|
+
}
|
|
3143
|
+
const last = messages.at(-1);
|
|
3144
|
+
cursorLatest = last ? getString5(last.ts) : undefined;
|
|
3145
|
+
if (!cursorLatest) {
|
|
3146
|
+
break;
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
return results;
|
|
3151
|
+
}
|
|
3152
|
+
function passesContentTypeFilter(m, contentType) {
|
|
3153
|
+
if (contentType === "any") {
|
|
3154
|
+
return true;
|
|
3155
|
+
}
|
|
3156
|
+
const hasFiles = Boolean(m.files && m.files.length > 0);
|
|
3157
|
+
if (contentType === "text") {
|
|
3158
|
+
return !hasFiles;
|
|
3159
|
+
}
|
|
3160
|
+
if (!hasFiles) {
|
|
3161
|
+
return false;
|
|
3162
|
+
}
|
|
3163
|
+
if (contentType === "file") {
|
|
3164
|
+
return true;
|
|
3165
|
+
}
|
|
3166
|
+
if (contentType === "snippet") {
|
|
3167
|
+
return (m.files ?? []).some((f) => f.mode === "snippet");
|
|
3168
|
+
}
|
|
3169
|
+
if (contentType === "image") {
|
|
3170
|
+
return (m.files ?? []).some((f) => String(f.mimetype ?? "").startsWith("image/"));
|
|
3171
|
+
}
|
|
3172
|
+
return true;
|
|
3173
|
+
}
|
|
3174
|
+
function stripThreadListFields(m) {
|
|
3175
|
+
const { channel_id: _channelId, thread_ts: _threadTs, ...rest } = m;
|
|
3176
|
+
return rest;
|
|
3177
|
+
}
|
|
3178
|
+
async function downloadFilesForMessage(input) {
|
|
3179
|
+
for (const f of input.message.files ?? []) {
|
|
3180
|
+
if (input.downloadedPaths[f.id]) {
|
|
3181
|
+
continue;
|
|
3182
|
+
}
|
|
3183
|
+
const url = f.url_private_download || f.url_private;
|
|
3184
|
+
if (!url) {
|
|
3185
|
+
continue;
|
|
3186
|
+
}
|
|
3187
|
+
const ext = inferExt2(f);
|
|
3188
|
+
const path = await downloadSlackFile({
|
|
3189
|
+
auth: input.auth,
|
|
3190
|
+
url,
|
|
3191
|
+
destDir: input.downloadsDir,
|
|
3192
|
+
preferredName: `${f.id}${ext ? `.${ext}` : ""}`
|
|
3193
|
+
});
|
|
3194
|
+
input.downloadedPaths[f.id] = path;
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
function messageSummaryFromApiMessage(channelId, msg) {
|
|
3198
|
+
const text = getString5(msg.text) ?? "";
|
|
3199
|
+
const files = asArray4(msg.files).map((f) => toSlackFileSummary2(f)).filter((f) => f !== null);
|
|
3200
|
+
return {
|
|
3201
|
+
channel_id: channelId,
|
|
3202
|
+
ts: getString5(msg.ts) ?? "",
|
|
3203
|
+
thread_ts: getString5(msg.thread_ts),
|
|
3204
|
+
reply_count: getNumber3(msg.reply_count),
|
|
3205
|
+
user: getString5(msg.user),
|
|
3206
|
+
bot_id: getString5(msg.bot_id),
|
|
3207
|
+
text,
|
|
3208
|
+
markdown: slackMrkdwnToMarkdown(text),
|
|
3209
|
+
blocks: Array.isArray(msg.blocks) ? msg.blocks : undefined,
|
|
3210
|
+
attachments: Array.isArray(msg.attachments) ? msg.attachments : undefined,
|
|
3211
|
+
files: files.length > 0 ? files : undefined,
|
|
3212
|
+
reactions: Array.isArray(msg.reactions) ? msg.reactions : undefined
|
|
3213
|
+
};
|
|
3214
|
+
}
|
|
3215
|
+
function toSlackFileSummary2(value) {
|
|
3216
|
+
if (!isRecord10(value)) {
|
|
3217
|
+
return null;
|
|
3218
|
+
}
|
|
3219
|
+
const id = getString5(value.id);
|
|
3220
|
+
if (!id) {
|
|
3221
|
+
return null;
|
|
3222
|
+
}
|
|
3223
|
+
return {
|
|
3224
|
+
id,
|
|
3225
|
+
name: getString5(value.name),
|
|
3226
|
+
title: getString5(value.title),
|
|
3227
|
+
mimetype: getString5(value.mimetype),
|
|
3228
|
+
filetype: getString5(value.filetype),
|
|
3229
|
+
mode: getString5(value.mode),
|
|
3230
|
+
permalink: getString5(value.permalink),
|
|
3231
|
+
url_private: getString5(value.url_private),
|
|
3232
|
+
url_private_download: getString5(value.url_private_download),
|
|
3233
|
+
size: getNumber3(value.size)
|
|
3234
|
+
};
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
// src/slack/search.ts
|
|
3238
|
+
async function searchSlack(input) {
|
|
3239
|
+
const limit = Math.min(Math.max(input.options.limit ?? 20, 1), 200);
|
|
3240
|
+
const maxContentChars = input.options.max_content_chars ?? 4000;
|
|
3241
|
+
const contentType = input.options.content_type ?? "any";
|
|
3242
|
+
const download = input.options.download ?? true;
|
|
3243
|
+
if (!download && (input.options.kind === "files" || input.options.kind === "all")) {
|
|
3244
|
+
throw new Error("File search requires downloads enabled (so agents get local file paths).");
|
|
3245
|
+
}
|
|
3246
|
+
const slackQuery = await buildSlackSearchQuery(input.client, {
|
|
3247
|
+
query: input.options.query,
|
|
3248
|
+
channels: input.options.channels,
|
|
3249
|
+
user: input.options.user,
|
|
3250
|
+
after: input.options.after,
|
|
3251
|
+
before: input.options.before
|
|
3252
|
+
});
|
|
3253
|
+
const out = {};
|
|
3254
|
+
if (input.options.kind === "messages" || input.options.kind === "all") {
|
|
3255
|
+
if (input.options.channels?.length) {
|
|
3256
|
+
out.messages = await searchMessagesInChannelsFallback(input.client, {
|
|
3257
|
+
auth: input.auth,
|
|
3258
|
+
query: input.options.query,
|
|
3259
|
+
channels: input.options.channels,
|
|
3260
|
+
user: input.options.user,
|
|
3261
|
+
after: input.options.after,
|
|
3262
|
+
before: input.options.before,
|
|
3263
|
+
limit,
|
|
3264
|
+
maxContentChars,
|
|
3265
|
+
contentType,
|
|
3266
|
+
download
|
|
3267
|
+
});
|
|
3268
|
+
} else {
|
|
3269
|
+
const rawMatches = await searchMessagesRaw(input.client, { query: slackQuery, limit });
|
|
3270
|
+
out.messages = await searchMessagesViaSearchApi(input.client, {
|
|
3271
|
+
auth: input.auth,
|
|
3272
|
+
workspace_url: input.options.workspace_url,
|
|
3273
|
+
slack_query: slackQuery,
|
|
3274
|
+
limit,
|
|
3275
|
+
maxContentChars,
|
|
3276
|
+
contentType,
|
|
3277
|
+
download,
|
|
3278
|
+
rawMatches
|
|
3279
|
+
});
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
if (input.options.kind === "files" || input.options.kind === "all") {
|
|
3283
|
+
if (input.options.channels?.length) {
|
|
3284
|
+
out.files = await searchFilesInChannelsFallback(input.client, {
|
|
3285
|
+
auth: input.auth,
|
|
3286
|
+
query: input.options.query,
|
|
3287
|
+
channels: input.options.channels,
|
|
3288
|
+
user: input.options.user,
|
|
3289
|
+
after: input.options.after,
|
|
3290
|
+
before: input.options.before,
|
|
3291
|
+
limit,
|
|
3292
|
+
contentType
|
|
3293
|
+
});
|
|
3294
|
+
} else {
|
|
3295
|
+
const rawMatches = await searchFilesRaw(input.client, { query: slackQuery, limit });
|
|
3296
|
+
out.files = await searchFilesViaSearchApi(input.client, {
|
|
3297
|
+
auth: input.auth,
|
|
3298
|
+
slack_query: slackQuery,
|
|
3299
|
+
limit,
|
|
3300
|
+
contentType,
|
|
3301
|
+
rawMatches
|
|
3302
|
+
});
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
return out;
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
// src/cli/search-command.ts
|
|
3309
|
+
function addSearchOptions(cmd) {
|
|
3310
|
+
return cmd.option("--workspace <url>", "Workspace URL (needed when searching across multiple workspaces)").option("--channel <channel...>", "Channel filter (#name, name, or id). Repeatable.").option("--user <user>", "User filter (@name, name, or user id U...)").option("--after <date>", "Only results after YYYY-MM-DD").option("--before <date>", "Only results before YYYY-MM-DD").option("--content-type <type>", "Filter content type: any|text|image|snippet|file (default any)").option("--limit <n>", "Max results (default 20)", "20").option("--max-content-chars <n>", "Max message content characters (default 4000, -1 for unlimited)", "4000");
|
|
3311
|
+
}
|
|
3312
|
+
async function runSearch(input) {
|
|
3313
|
+
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
|
|
3314
|
+
const channels = Array.isArray(input.options.channel) ? input.options.channel : input.options.channel ? [input.options.channel] : [];
|
|
3315
|
+
await input.ctx.assertWorkspaceSpecifiedForChannelNames({ workspaceUrl, channels });
|
|
3316
|
+
const payload = await input.ctx.withAutoRefresh({
|
|
3317
|
+
workspaceUrl,
|
|
3318
|
+
work: async () => {
|
|
3319
|
+
const { client, auth, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
|
|
3320
|
+
const limit = Number.parseInt(input.options.limit || "20", 10);
|
|
3321
|
+
const maxContentChars = Number.parseInt(input.options.maxContentChars || "4000", 10);
|
|
3322
|
+
const contentType = input.ctx.parseContentType(input.options.contentType);
|
|
3323
|
+
return await searchSlack({
|
|
3324
|
+
client,
|
|
3325
|
+
auth,
|
|
3326
|
+
options: {
|
|
3327
|
+
workspace_url: workspace_url ?? workspaceUrl ?? "",
|
|
3328
|
+
query: input.query,
|
|
3329
|
+
kind: input.kind,
|
|
3330
|
+
channels,
|
|
3331
|
+
user: input.options.user,
|
|
3332
|
+
after: input.options.after,
|
|
3333
|
+
before: input.options.before,
|
|
3334
|
+
content_type: contentType,
|
|
3335
|
+
limit,
|
|
3336
|
+
max_content_chars: maxContentChars,
|
|
3337
|
+
download: true
|
|
3338
|
+
}
|
|
3339
|
+
});
|
|
3340
|
+
}
|
|
3341
|
+
});
|
|
3342
|
+
console.log(JSON.stringify(pruneEmpty(payload), null, 2));
|
|
3343
|
+
}
|
|
3344
|
+
function registerSearchCommand(input) {
|
|
3345
|
+
const searchCmd = input.program.command("search").description("Search Slack messages and files (token-efficient JSON)");
|
|
3346
|
+
const create = (spec) => addSearchOptions(searchCmd.command(spec.name).description(spec.desc)).argument("<query>", "Search query").action(async (...args) => {
|
|
3347
|
+
const [query, options] = args;
|
|
3348
|
+
try {
|
|
3349
|
+
await runSearch({ ctx: input.ctx, kind: spec.kind, query, options });
|
|
3350
|
+
} catch (err) {
|
|
3351
|
+
console.error(input.ctx.errorMessage(err));
|
|
3352
|
+
process.exitCode = 1;
|
|
3353
|
+
}
|
|
3354
|
+
});
|
|
3355
|
+
create({ kind: "all", name: "all", desc: "Search messages and files" });
|
|
3356
|
+
create({ kind: "messages", name: "messages", desc: "Search messages" });
|
|
3357
|
+
create({ kind: "files", name: "files", desc: "Search files" });
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
// src/lib/update.ts
|
|
3361
|
+
import { createHash } from "node:crypto";
|
|
3362
|
+
import { chmod, copyFile, mkdir as mkdir5, readFile as readFile4, rename, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
|
|
3363
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
3364
|
+
import { join as join8 } from "node:path";
|
|
3365
|
+
var REPO = "stablyai/agent-slack";
|
|
3366
|
+
var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
3367
|
+
function getCachePath() {
|
|
3368
|
+
return join8(getAppDir(), "update-check.json");
|
|
3369
|
+
}
|
|
3370
|
+
function compareSemver(a, b) {
|
|
3371
|
+
const pa = a.replace(/^v/, "").split(".").map(Number);
|
|
3372
|
+
const pb = b.replace(/^v/, "").split(".").map(Number);
|
|
3373
|
+
for (let i = 0;i < 3; i++) {
|
|
3374
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
3375
|
+
if (diff !== 0) {
|
|
3376
|
+
return diff;
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
return 0;
|
|
3380
|
+
}
|
|
3381
|
+
async function fetchLatestVersion() {
|
|
3382
|
+
try {
|
|
3383
|
+
const resp = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
|
|
3384
|
+
headers: { Accept: "application/vnd.github+json", "User-Agent": "agent-slack-updater" },
|
|
3385
|
+
signal: AbortSignal.timeout(5000)
|
|
3386
|
+
});
|
|
3387
|
+
if (!resp.ok) {
|
|
3388
|
+
return null;
|
|
3389
|
+
}
|
|
3390
|
+
const data = await resp.json();
|
|
3391
|
+
return data.tag_name?.replace(/^v/, "") ?? null;
|
|
3392
|
+
} catch {
|
|
3393
|
+
return null;
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
async function checkForUpdate(force = false) {
|
|
3397
|
+
const current = getPackageVersion();
|
|
3398
|
+
if (!force) {
|
|
3399
|
+
const cached = await readJsonFile(getCachePath());
|
|
3400
|
+
if (cached && Date.now() - cached.checked_at < CHECK_INTERVAL_MS) {
|
|
3401
|
+
return {
|
|
3402
|
+
current,
|
|
3403
|
+
latest: cached.latest_version,
|
|
3404
|
+
update_available: compareSemver(cached.latest_version, current) > 0
|
|
3405
|
+
};
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
const latest = await fetchLatestVersion();
|
|
3409
|
+
if (!latest) {
|
|
3410
|
+
return null;
|
|
3411
|
+
}
|
|
3412
|
+
try {
|
|
3413
|
+
await writeJsonFile(getCachePath(), {
|
|
3414
|
+
latest_version: latest,
|
|
3415
|
+
checked_at: Date.now()
|
|
3416
|
+
});
|
|
3417
|
+
} catch {}
|
|
3418
|
+
return {
|
|
3419
|
+
current,
|
|
3420
|
+
latest,
|
|
3421
|
+
update_available: compareSemver(latest, current) > 0
|
|
3422
|
+
};
|
|
3423
|
+
}
|
|
3424
|
+
function detectPlatformAsset() {
|
|
3425
|
+
const platform4 = process.platform === "win32" ? "windows" : process.platform;
|
|
3426
|
+
const archMap = { x64: "x64", arm64: "arm64" };
|
|
3427
|
+
const arch = archMap[process.arch] ?? process.arch;
|
|
3428
|
+
const ext = platform4 === "windows" ? ".exe" : "";
|
|
3429
|
+
return `agent-slack-${platform4}-${arch}${ext}`;
|
|
3430
|
+
}
|
|
3431
|
+
async function sha256(filePath) {
|
|
3432
|
+
const data = await readFile4(filePath);
|
|
3433
|
+
return createHash("sha256").update(data).digest("hex");
|
|
3434
|
+
}
|
|
3435
|
+
async function performUpdate(latest) {
|
|
3436
|
+
const asset = detectPlatformAsset();
|
|
3437
|
+
const tag = `v${latest}`;
|
|
3438
|
+
const baseUrl = `https://github.com/${REPO}/releases/download/${tag}`;
|
|
3439
|
+
const tmp = join8(tmpdir2(), `agent-slack-update-${Date.now()}`);
|
|
3440
|
+
await mkdir5(tmp, { recursive: true });
|
|
3441
|
+
const binTmp = join8(tmp, asset);
|
|
3442
|
+
const sumsTmp = join8(tmp, "checksums-sha256.txt");
|
|
3443
|
+
try {
|
|
3444
|
+
const [binResp, sumsResp] = await Promise.all([
|
|
3445
|
+
fetch(`${baseUrl}/${asset}`, { signal: AbortSignal.timeout(120000) }),
|
|
3446
|
+
fetch(`${baseUrl}/checksums-sha256.txt`, { signal: AbortSignal.timeout(30000) })
|
|
3447
|
+
]);
|
|
3448
|
+
if (!binResp.ok) {
|
|
3449
|
+
return { success: false, message: `Failed to download ${asset}: HTTP ${binResp.status}` };
|
|
3450
|
+
}
|
|
3451
|
+
if (!sumsResp.ok) {
|
|
3452
|
+
return { success: false, message: `Failed to download checksums: HTTP ${sumsResp.status}` };
|
|
3453
|
+
}
|
|
3454
|
+
await writeFile3(binTmp, Buffer.from(await binResp.arrayBuffer()));
|
|
3455
|
+
const sumsText = await sumsResp.text();
|
|
3456
|
+
await writeFile3(sumsTmp, sumsText);
|
|
3457
|
+
const expected = sumsText.split(`
|
|
3458
|
+
`).map((line) => line.trim().split(/\s+/)).find((parts) => parts[1] === asset)?.[0];
|
|
3459
|
+
if (!expected) {
|
|
3460
|
+
return { success: false, message: `Checksum not found for ${asset} in release checksums` };
|
|
3461
|
+
}
|
|
3462
|
+
const actual = await sha256(binTmp);
|
|
3463
|
+
if (actual !== expected) {
|
|
3464
|
+
return { success: false, message: `Checksum mismatch: expected ${expected}, got ${actual}` };
|
|
3465
|
+
}
|
|
3466
|
+
const currentBin = process.execPath;
|
|
3467
|
+
const backupPath = `${currentBin}.bak`;
|
|
3468
|
+
await rename(currentBin, backupPath);
|
|
3469
|
+
try {
|
|
3470
|
+
await copyFile(binTmp, currentBin);
|
|
3471
|
+
await chmod(currentBin, 493);
|
|
3472
|
+
await rm2(backupPath, { force: true });
|
|
3473
|
+
} catch (err) {
|
|
3474
|
+
try {
|
|
3475
|
+
await rename(backupPath, currentBin);
|
|
3476
|
+
} catch {}
|
|
3477
|
+
throw err;
|
|
3478
|
+
}
|
|
3479
|
+
return { success: true, message: `Updated agent-slack to ${latest}` };
|
|
3480
|
+
} finally {
|
|
3481
|
+
await rm2(tmp, { recursive: true, force: true }).catch(() => {});
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
async function backgroundUpdateCheck() {
|
|
3485
|
+
if (process.env.AGENT_SLACK_NO_UPDATE_CHECK === "1") {
|
|
3486
|
+
return;
|
|
3487
|
+
}
|
|
3488
|
+
try {
|
|
3489
|
+
const result = await checkForUpdate();
|
|
3490
|
+
if (result?.update_available) {
|
|
3491
|
+
process.stderr.write(`
|
|
3492
|
+
Update available: ${result.current} → ${result.latest}. Run "agent-slack update" to upgrade.
|
|
3493
|
+
`);
|
|
3494
|
+
}
|
|
3495
|
+
} catch {}
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
// src/cli/update-command.ts
|
|
3499
|
+
function registerUpdateCommand(input) {
|
|
3500
|
+
input.program.command("update").description("Update agent-slack to the latest version").option("--check", "Only check for updates (don't install)").action(async (...args) => {
|
|
3501
|
+
const [options] = args;
|
|
3502
|
+
try {
|
|
3503
|
+
const result = await checkForUpdate(true);
|
|
3504
|
+
if (!result) {
|
|
3505
|
+
console.error("Could not check for updates. Check your network connection.");
|
|
3506
|
+
process.exitCode = 1;
|
|
3507
|
+
return;
|
|
3508
|
+
}
|
|
3509
|
+
if (!result.update_available) {
|
|
3510
|
+
console.log(JSON.stringify(pruneEmpty({ ...result, status: "up_to_date" }), null, 2));
|
|
3511
|
+
return;
|
|
3512
|
+
}
|
|
3513
|
+
if (options.check) {
|
|
3514
|
+
console.log(JSON.stringify(pruneEmpty({ ...result, status: "update_available" }), null, 2));
|
|
3515
|
+
return;
|
|
3516
|
+
}
|
|
3517
|
+
process.stderr.write(`Updating agent-slack ${result.current} → ${result.latest}...
|
|
3518
|
+
`);
|
|
3519
|
+
const outcome = await performUpdate(result.latest);
|
|
3520
|
+
if (!outcome.success) {
|
|
3521
|
+
console.error(outcome.message);
|
|
3522
|
+
process.exitCode = 1;
|
|
3523
|
+
return;
|
|
3524
|
+
}
|
|
3525
|
+
console.log(JSON.stringify(pruneEmpty({
|
|
3526
|
+
status: "updated",
|
|
3527
|
+
previous_version: result.current,
|
|
3528
|
+
new_version: result.latest,
|
|
3529
|
+
message: outcome.message
|
|
3530
|
+
}), null, 2));
|
|
3531
|
+
} catch (err) {
|
|
3532
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
3533
|
+
process.exitCode = 1;
|
|
3534
|
+
}
|
|
3535
|
+
});
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
// src/slack/users.ts
|
|
3539
|
+
async function listUsers(client, options) {
|
|
3540
|
+
const limit = Math.min(Math.max(options?.limit ?? 200, 1), 1000);
|
|
3541
|
+
const includeBots = options?.includeBots ?? false;
|
|
3542
|
+
const out = [];
|
|
3543
|
+
let cursor = options?.cursor;
|
|
3544
|
+
while (out.length < limit) {
|
|
3545
|
+
const pageSize = Math.min(200, limit - out.length);
|
|
3546
|
+
const resp = await client.api("users.list", {
|
|
3547
|
+
limit: pageSize,
|
|
3548
|
+
cursor
|
|
3549
|
+
});
|
|
3550
|
+
const members = asArray5(resp.members).filter(isRecord11);
|
|
3551
|
+
for (const m of members) {
|
|
3552
|
+
const id = getString6(m.id);
|
|
3553
|
+
if (!id) {
|
|
3554
|
+
continue;
|
|
3555
|
+
}
|
|
3556
|
+
if (!includeBots && m.is_bot) {
|
|
3557
|
+
continue;
|
|
3558
|
+
}
|
|
3559
|
+
out.push(toCompactUser(m));
|
|
3560
|
+
if (out.length >= limit) {
|
|
3561
|
+
break;
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
const meta = isRecord11(resp.response_metadata) ? resp.response_metadata : null;
|
|
3565
|
+
const next = meta ? getString6(meta.next_cursor) : undefined;
|
|
3566
|
+
if (!next) {
|
|
3567
|
+
return { users: out };
|
|
3568
|
+
}
|
|
3569
|
+
cursor = next;
|
|
3570
|
+
}
|
|
3571
|
+
return { users: out, next_cursor: cursor };
|
|
3572
|
+
}
|
|
3573
|
+
async function getUser(client, input) {
|
|
3574
|
+
const trimmed = input.trim();
|
|
3575
|
+
if (!trimmed) {
|
|
3576
|
+
throw new Error("User is empty");
|
|
3577
|
+
}
|
|
3578
|
+
const userId = await resolveUserId2(client, trimmed);
|
|
3579
|
+
if (!userId) {
|
|
3580
|
+
throw new Error(`Could not resolve user: ${input}`);
|
|
3581
|
+
}
|
|
3582
|
+
const resp = await client.api("users.info", { user: userId });
|
|
3583
|
+
const u = isRecord11(resp.user) ? resp.user : null;
|
|
3584
|
+
if (!u || !getString6(u.id)) {
|
|
3585
|
+
throw new Error("users.info returned no user");
|
|
3586
|
+
}
|
|
3587
|
+
return toCompactUser(u);
|
|
3588
|
+
}
|
|
3589
|
+
async function resolveUserId2(client, input) {
|
|
3590
|
+
const trimmed = input.trim();
|
|
3591
|
+
if (/^U[A-Z0-9]{8,}$/.test(trimmed)) {
|
|
3592
|
+
return trimmed;
|
|
3593
|
+
}
|
|
3594
|
+
const handle = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
3595
|
+
if (!handle) {
|
|
3596
|
+
return null;
|
|
3597
|
+
}
|
|
3598
|
+
let cursor;
|
|
3599
|
+
for (;; ) {
|
|
3600
|
+
const resp = await client.api("users.list", { limit: 200, cursor });
|
|
3601
|
+
const members = asArray5(resp.members).filter(isRecord11);
|
|
3602
|
+
const found = members.find((m) => getString6(m.name) === handle);
|
|
3603
|
+
if (found) {
|
|
3604
|
+
const id = getString6(found.id);
|
|
3605
|
+
if (id) {
|
|
3606
|
+
return id;
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
const meta = isRecord11(resp.response_metadata) ? resp.response_metadata : null;
|
|
3610
|
+
const next = meta ? getString6(meta.next_cursor) : undefined;
|
|
3611
|
+
if (!next) {
|
|
3612
|
+
break;
|
|
3613
|
+
}
|
|
3614
|
+
cursor = next;
|
|
3615
|
+
}
|
|
3616
|
+
return null;
|
|
3617
|
+
}
|
|
3618
|
+
function toCompactUser(u) {
|
|
3619
|
+
const profile = isRecord11(u.profile) ? u.profile : {};
|
|
3620
|
+
return {
|
|
3621
|
+
id: getString6(u.id) ?? "",
|
|
3622
|
+
name: getString6(u.name) ?? undefined,
|
|
3623
|
+
real_name: getString6(u.real_name) ?? undefined,
|
|
3624
|
+
display_name: getString6(profile.display_name) ?? undefined,
|
|
3625
|
+
email: getString6(profile.email) ?? undefined,
|
|
3626
|
+
title: getString6(profile.title) ?? undefined,
|
|
3627
|
+
tz: getString6(u.tz) ?? undefined,
|
|
3628
|
+
is_bot: typeof u.is_bot === "boolean" ? u.is_bot : undefined,
|
|
3629
|
+
deleted: typeof u.deleted === "boolean" ? u.deleted : undefined
|
|
3630
|
+
};
|
|
3631
|
+
}
|
|
3632
|
+
function isRecord11(value) {
|
|
3633
|
+
return typeof value === "object" && value !== null;
|
|
3634
|
+
}
|
|
3635
|
+
function asArray5(value) {
|
|
3636
|
+
return Array.isArray(value) ? value : [];
|
|
3637
|
+
}
|
|
3638
|
+
function getString6(value) {
|
|
3639
|
+
return typeof value === "string" ? value : undefined;
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
// src/cli/user-command.ts
|
|
3643
|
+
function registerUserCommand(input) {
|
|
3644
|
+
const userCmd = input.program.command("user").description("Workspace user directory");
|
|
3645
|
+
userCmd.command("list").description("List users in the workspace").option("--workspace <url>", "Workspace URL (required if you have multiple workspaces)").option("--limit <n>", "Max users (default 200)", "200").option("--cursor <cursor>", "Pagination cursor").option("--include-bots", "Include bot users").action(async (...args) => {
|
|
3646
|
+
const [options] = args;
|
|
3647
|
+
try {
|
|
3648
|
+
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace);
|
|
3649
|
+
const payload = await input.ctx.withAutoRefresh({
|
|
3650
|
+
workspaceUrl,
|
|
3651
|
+
work: async () => {
|
|
3652
|
+
const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
|
|
3653
|
+
const limit = Number.parseInt(options.limit, 10);
|
|
3654
|
+
return await listUsers(client, {
|
|
3655
|
+
limit,
|
|
3656
|
+
cursor: options.cursor,
|
|
3657
|
+
includeBots: Boolean(options.includeBots)
|
|
3658
|
+
});
|
|
3659
|
+
}
|
|
3660
|
+
});
|
|
3661
|
+
console.log(JSON.stringify(pruneEmpty(payload), null, 2));
|
|
3662
|
+
} catch (err) {
|
|
3663
|
+
console.error(input.ctx.errorMessage(err));
|
|
3664
|
+
process.exitCode = 1;
|
|
3665
|
+
}
|
|
3666
|
+
});
|
|
3667
|
+
userCmd.command("get").description("Get a single user by id (U...) or handle (@name)").argument("<user>", "User id (U...) or @handle/handle").option("--workspace <url>", "Workspace URL (required if you have multiple workspaces)").action(async (...args) => {
|
|
3668
|
+
const [user, options] = args;
|
|
3669
|
+
try {
|
|
3670
|
+
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace);
|
|
3671
|
+
const payload = await input.ctx.withAutoRefresh({
|
|
3672
|
+
workspaceUrl,
|
|
3673
|
+
work: async () => {
|
|
3674
|
+
const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
|
|
3675
|
+
return await getUser(client, user);
|
|
3676
|
+
}
|
|
3677
|
+
});
|
|
3678
|
+
console.log(JSON.stringify(pruneEmpty(payload), null, 2));
|
|
3679
|
+
} catch (err) {
|
|
3680
|
+
console.error(input.ctx.errorMessage(err));
|
|
3681
|
+
process.exitCode = 1;
|
|
3682
|
+
}
|
|
3683
|
+
});
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
// src/index.ts
|
|
3687
|
+
var program = new Command;
|
|
3688
|
+
program.name("agent-slack").description("Slack automation CLI for AI agents").version(getPackageVersion());
|
|
3689
|
+
var ctx = createCliContext();
|
|
3690
|
+
registerAuthCommand({ program, ctx });
|
|
3691
|
+
registerMessageCommand({ program, ctx });
|
|
3692
|
+
registerCanvasCommand({ program, ctx });
|
|
3693
|
+
registerSearchCommand({ program, ctx });
|
|
3694
|
+
registerUpdateCommand({ program });
|
|
3695
|
+
registerUserCommand({ program, ctx });
|
|
3696
|
+
program.parse(process.argv);
|
|
3697
|
+
if (!process.argv.slice(2).length) {
|
|
3698
|
+
program.outputHelp();
|
|
3699
|
+
}
|
|
3700
|
+
var [subcommand] = process.argv.slice(2);
|
|
3701
|
+
if (subcommand && subcommand !== "update") {
|
|
3702
|
+
backgroundUpdateCheck();
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
//# debugId=09DBB443871FCCB664756E2164756E21
|
|
3706
|
+
//# sourceMappingURL=index.js.map
|