slack-term 1.41.2 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/newlines.ts ADDED
@@ -0,0 +1,158 @@
1
+ // Line breaks for messages Slack stored flattened.
2
+ //
3
+ // WHY: `send`/`edit`/`schedule` attach a `markdown` block so Slack renders
4
+ // markdown. Whenever a message has blocks, Slack stores its top-level `text`
5
+ // with every newline turned into a space. The rendered message looks right,
6
+ // but every reader of `.text` sees one long line: `read --format jsonl`, ask's
7
+ // parser, and any script on conversations.history.
8
+ // Measured 2026-10-07:
9
+ // - 126 of 127 multi-line sends were stored that way;
10
+ // - the stored text is the source with `\n` → ` ` plus Slack's escaping;
11
+ // - a live send with our own rich_text blocks was flattened the same way, so
12
+ // no blocks keep `.text`, only a plain post does (`ask`, `poll`).
13
+ //
14
+ // The blocks still carry the breaks. Readers use them to put the breaks back.
15
+
16
+ import type { Json } from "./slack.ts";
17
+
18
+ const ESC: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;" };
19
+
20
+ /** What must appear, in order, in the message's stored `text`: one character,
21
+ * or (an emoji) any one of several spellings. */
22
+ type Anchor = string | string[];
23
+
24
+ /** The glyph forms of an emoji element (`unicode: "2705-fe0f"`), with and
25
+ * without the variation selector — the stored text may hold either. */
26
+ function emojiGlyphs(unicode: Json | undefined): string[] {
27
+ if (typeof unicode !== "string" || !/^[0-9a-f]+(-[0-9a-f]+)*$/i.test(unicode)) return [];
28
+ const cps = unicode.split("-").map((h) => parseInt(h, 16));
29
+ const full = String.fromCodePoint(...cps);
30
+ const bare = String.fromCodePoint(...cps.filter((c) => c !== 0xfe0f));
31
+ return full === bare ? [full] : [full, bare];
32
+ }
33
+
34
+ /** The anchors of `blocks`, with `null` wherever the blocks put a line break. */
35
+ function anchors(blocks: Json[]): Array<Anchor | null> {
36
+ const out: Array<Anchor | null> = [];
37
+ const chars = (s: string) => { for (const ch of s) { if (ch === "\n") out.push(null); else if (!/\s/u.test(ch)) out.push(...(ESC[ch] ?? ch)); } };
38
+ const inline = (e: Record<string, Json>) => {
39
+ switch (e.type) {
40
+ case "text": chars(String(e.text ?? "")); break;
41
+ case "link": case "message_mention": chars(String(e.url ?? "")); break;
42
+ case "user": chars(String(e.user_id ?? "")); break;
43
+ case "channel": chars(String(e.channel_id ?? "")); break;
44
+ case "usergroup": chars(String(e.usergroup_id ?? "")); break;
45
+ case "broadcast": chars(String(e.range ?? "")); break;
46
+ // `:name:` in most stored texts, but a literal glyph in some.
47
+ case "emoji": out.push([`:${String(e.name ?? "")}:`, ...emojiGlyphs(e.unicode)].filter((x) => x !== "::")); break;
48
+ default: break;
49
+ }
50
+ };
51
+ const container = (b: Record<string, Json>) => {
52
+ const els = Array.isArray(b.elements) ? (b.elements as Record<string, Json>[]) : [];
53
+ if (b.type === "rich_text_list" || b.type === "rich_text") {
54
+ els.forEach((x, k) => { if (k > 0) out.push(null); container(x); });
55
+ } else {
56
+ els.forEach(inline);
57
+ }
58
+ };
59
+ blocks.forEach((b, k) => {
60
+ if (k > 0) out.push(null);
61
+ const r = b as Record<string, Json>;
62
+ if (r.type === "rich_text") container(r);
63
+ });
64
+ return out;
65
+ }
66
+
67
+ /** Earliest position at or after `from` where `a` (any of its spellings)
68
+ * occurs in `chars`, and its length in code points; [-1, 0] if nowhere. */
69
+ function find(chars: string[], a: Anchor, from: number): [number, number] {
70
+ if (typeof a === "string") {
71
+ let q = from;
72
+ while (q < chars.length && chars[q] !== a) q++;
73
+ return q < chars.length ? [q, 1] : [-1, 0];
74
+ }
75
+ let best: [number, number] = [-1, 0];
76
+ for (const alt of a) {
77
+ const cps = [...alt];
78
+ for (let q = from; q + cps.length <= chars.length && (best[0] < 0 || q < best[0]); q++) {
79
+ if (cps.every((c, k) => chars[q + k] === c)) { best = [q, cps.length]; break; }
80
+ }
81
+ }
82
+ return best;
83
+ }
84
+
85
+ /** `text` with its line breaks put back, for a message Slack stored flattened
86
+ * (it had blocks — see the top of this file). Slack replaced
87
+ * every `\n` of the source with one space; the `rich_text` blocks still carry
88
+ * the breaks. Walk the blocks' visible characters through `text` and, at each
89
+ * place the blocks break a line, turn the spaces there back into newlines.
90
+ *
91
+ * Only ever turns spaces into newlines, so the result is the stored text when
92
+ * breaks are ignored — what a reader saw before, never anything new. Returns
93
+ * `text` unchanged unless it is single-line and the blocks are multi-line,
94
+ * or when the two cannot be aligned. */
95
+ export function restoreNewlines(text: string, blocks: Json | undefined): string {
96
+ if (text.includes("\n") || !Array.isArray(blocks) || !blocks.some((b) => (b as Record<string, Json>)?.type === "rich_text")) return text;
97
+ const seq = anchors(blocks);
98
+ if (!seq.includes(null)) return text;
99
+ const chars = [...text];
100
+ // Map each anchor to its position in `text`; a code block at either end
101
+ // also has a break between its fence and its first/last character.
102
+ const elems = (blocks[0] as Record<string, Json>).elements;
103
+ const lastBlock = blocks[blocks.length - 1] as Record<string, Json>;
104
+ const lastElems = lastBlock.elements;
105
+ const startsPre = Array.isArray(elems) && (elems[0] as Record<string, Json>)?.type === "rich_text_preformatted";
106
+ const endsPre = Array.isArray(lastElems) && (lastElems[lastElems.length - 1] as Record<string, Json>)?.type === "rich_text_preformatted";
107
+ // `last` = -1 before the first anchor, so a leading break covers the gap
108
+ // from the start of the text.
109
+ let p = 0, last = -1, pendingBreak = startsPre;
110
+ const breaks: Array<[number, number]> = [];
111
+ for (const a of seq) {
112
+ if (a === null) { pendingBreak = true; continue; }
113
+ const [q, len] = find(chars, a, p);
114
+ if (q < 0) return text;
115
+ if (pendingBreak) { breaks.push([last + 1, q]); pendingBreak = false; }
116
+ last = q + len - 1; p = q + len;
117
+ }
118
+ if ((pendingBreak || endsPre) && last >= 0) breaks.push([last + 1, chars.length]);
119
+ const MARKER = /^(?:[-*+•]|\d{1,9}[.)]|&gt;|>)$/;
120
+ for (const [gapFrom, to] of breaks) {
121
+ // The gap between two visible characters holds the old newline(s) as
122
+ // spaces plus markup: the rest of a `<url|label>` (skip past its `>`),
123
+ // emphasis, `- `/`1. `/`&gt; ` markers, code fences. A space after a
124
+ // marker that itself starts a line is the marker's own, not a break.
125
+ let k = gapFrom;
126
+ // Inside a `<url|label>` token (its `<` opened before the gap and is not
127
+ // yet closed) the rest of the token up to its `>` is not a line break.
128
+ if (chars.lastIndexOf("<", gapFrom - 1) > chars.lastIndexOf(">", gapFrom - 1)) {
129
+ const close = chars.indexOf(">", gapFrom);
130
+ if (close !== -1 && close < to) k = close + 1;
131
+ }
132
+ let word = "", lineStart = gapFrom === 0;
133
+ while (k < to) {
134
+ if (chars[k] === " ") {
135
+ let e = k; while (e < to && chars[e] === " ") e++;
136
+ if (lineStart && MARKER.test(word)) {
137
+ lineStart = false;
138
+ } else {
139
+ // One space per old newline — but three or more are a newline
140
+ // followed by indentation (`\n - nested`), not a run of blank lines.
141
+ const n = e - k <= 2 ? e - k : 1;
142
+ for (let x = k; x < k + n; x++) chars[x] = "\n";
143
+ lineStart = true;
144
+ }
145
+ word = ""; k = e;
146
+ } else { word += chars[k]; k++; }
147
+ }
148
+ }
149
+ return chars.join("");
150
+ }
151
+
152
+ /** `restoreNewlines` applied in place to a conversations.history /
153
+ * conversations.replies message, so every reader of `.text` sees the breaks. */
154
+ export function repairMessageText(m: Json): void {
155
+ if (!m || typeof m !== "object" || Array.isArray(m)) return;
156
+ const r = m as Record<string, Json>;
157
+ if (typeof r.text === "string") r.text = restoreNewlines(r.text, r.blocks);
158
+ }
package/ts/relay.ts ADDED
@@ -0,0 +1,105 @@
1
+ // Client for the slack-term relay (worker/): a Cloudflare Worker that receives
2
+ // Slack's Events API and serves "doorbells" — {channel, ts, thread_ts}, never
3
+ // message text — as a resumable Server-Sent Events stream. `slack stream` uses
4
+ // it to scan the one channel a bell names within a second of the post, instead
5
+ // of waiting for its next poll.
6
+ //
7
+ // SSE over fetch (not WebSocket) so the bearer token travels in a header on
8
+ // both bun and node, and never in a URL.
9
+
10
+ /** A bell: where a message just appeared. */
11
+ export type Doorbell = {
12
+ channel: string; ts: string; thread_ts?: string;
13
+ /** The relay app's bot user id, on that bot's own posts only. */
14
+ self?: string;
15
+ };
16
+
17
+ /** First frame of every connection. `seq` is the resume point the relay
18
+ * starts from; `gap` means bells after the requested seq are gone (expired,
19
+ * or the relay was reset), so the client must catch up by polling. `epoch`
20
+ * names the relay's storage: a new one means its numbering restarted. */
21
+ export type Hello = { seq: number; gap: boolean; retention_sec: number; epoch?: string };
22
+
23
+ /** Where to resume: the last seq taken, in the numbering of `epoch`. */
24
+ export type Cursor = { seq: number; epoch?: string };
25
+
26
+ export type RelayHandlers = {
27
+ hello(h: Hello): void;
28
+ bell(seq: number, b: Doorbell): void;
29
+ };
30
+
31
+ /** Subscribe from `after` (undefined = from now). Resolves when the server
32
+ * closes the stream; rejects on HTTP errors, network errors and silence
33
+ * longer than `idleMs` (the relay sends a keepalive every 25 s). */
34
+ export type Subscribe = (after: Cursor | undefined, signal: AbortSignal, on: RelayHandlers) => Promise<void>;
35
+
36
+ export class RelayAuthError extends Error {}
37
+
38
+ /** Parse SSE frames out of a growing buffer; returns the unparsed tail. */
39
+ export function parseSse(buf: string, emit: (event: string, data: string, id: string | undefined) => void): string {
40
+ let i: number;
41
+ while ((i = buf.indexOf("\n\n")) >= 0) {
42
+ const frame = buf.slice(0, i);
43
+ buf = buf.slice(i + 2);
44
+ let event = "message";
45
+ let id: string | undefined;
46
+ const data: string[] = [];
47
+ for (const line of frame.split("\n")) {
48
+ if (!line || line.startsWith(":")) continue; // keepalive / comment
49
+ const c = line.indexOf(":");
50
+ const field = c < 0 ? line : line.slice(0, c);
51
+ const value = c < 0 ? "" : line.slice(c + 1).replace(/^ /, "");
52
+ if (field === "event") event = value;
53
+ else if (field === "data") data.push(value);
54
+ else if (field === "id") id = value;
55
+ }
56
+ if (data.length) emit(event, data.join("\n"), id);
57
+ }
58
+ return buf;
59
+ }
60
+
61
+ export function relaySubscriber(baseUrl: string, token: string, idleMs = 70_000): Subscribe {
62
+ const base = baseUrl.replace(/\/+$/, "");
63
+ return async (after, signal, on) => {
64
+ const ac = new AbortController();
65
+ const stop = (): void => ac.abort();
66
+ signal.addEventListener("abort", stop, { once: true });
67
+ let idle: ReturnType<typeof setTimeout> | undefined;
68
+ let silent = false;
69
+ const arm = (): void => {
70
+ clearTimeout(idle);
71
+ idle = setTimeout(() => { silent = true; ac.abort(); }, idleMs);
72
+ };
73
+ try {
74
+ arm();
75
+ const q = after === undefined ? "" : `?after=${after.seq}${after.epoch ? `&epoch=${encodeURIComponent(after.epoch)}` : ""}`;
76
+ const res = await fetch(`${base}/stream${q}`, {
77
+ headers: { authorization: `Bearer ${token}`, accept: "text/event-stream" },
78
+ signal: ac.signal,
79
+ });
80
+ if (res.status === 401) throw new RelayAuthError("relay rejected the token (401)");
81
+ if (!res.ok || !res.body) throw new Error(`relay: HTTP ${res.status}`);
82
+ const reader = res.body.getReader();
83
+ const dec = new TextDecoder();
84
+ let buf = "";
85
+ for (;;) {
86
+ const { done, value } = await reader.read();
87
+ if (done) return;
88
+ arm();
89
+ // CRLF is normalised on the whole buffer: a \r\n pair can straddle chunks.
90
+ buf = parseSse((buf + dec.decode(value, { stream: true })).replace(/\r\n/g, "\n"), (event, data, id) => {
91
+ const j = JSON.parse(data) as unknown;
92
+ if (event === "hello") on.hello(j as Hello);
93
+ else if (event === "bell" && id !== undefined) on.bell(Number(id), j as Doorbell);
94
+ });
95
+ }
96
+ } catch (e) {
97
+ if (signal.aborted) return;
98
+ if (silent) throw new Error(`relay: no data for ${Math.round(idleMs / 1000)}s`);
99
+ throw e;
100
+ } finally {
101
+ clearTimeout(idle);
102
+ signal.removeEventListener("abort", stop);
103
+ }
104
+ };
105
+ }
package/ts/sentlog.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  // Opt out with SLACK_TERM_ATTRIBUTION=off (no log, no metadata).
20
20
 
21
21
  import { spawnSync } from "node:child_process";
22
- import { chmodSync, mkdirSync } from "node:fs";
22
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
23
23
  import { homedir, hostname } from "node:os";
24
24
  import { basename, dirname, join } from "node:path";
25
25
 
@@ -63,7 +63,13 @@ export interface SentRow {
63
63
  collected_at?: number | null;
64
64
  answer?: string | null;
65
65
  answer_exit?: number | null;
66
+ /** Set whenever `--wait`/`--waitFor` handed an answer to its caller — the
67
+ * decision (exit 0) or a free-text reply (exit 5) — with WHICH answer, and
68
+ * the `--after` cursor that delivery ended at. `ask --pending` skips an
69
+ * answer already delivered, and resumes from that cursor. */
66
70
  delivered_at?: number | null;
71
+ delivered_answer?: string | null;
72
+ delivered_cursor?: string | null;
67
73
  }
68
74
 
69
75
  export function attributionEnabled(): boolean {
@@ -231,7 +237,9 @@ CREATE TABLE IF NOT EXISTS sent (
231
237
  collected_at INTEGER,
232
238
  answer TEXT,
233
239
  answer_exit INTEGER,
234
- delivered_at INTEGER
240
+ delivered_at INTEGER,
241
+ delivered_answer TEXT,
242
+ delivered_cursor TEXT
235
243
  );
236
244
  CREATE INDEX IF NOT EXISTS sent_msg ON sent(channel, ts);
237
245
  CREATE INDEX IF NOT EXISTS sent_at ON sent(sent_at);
@@ -252,6 +260,11 @@ export function openSentLog(): Db | undefined {
252
260
  try { chmodSync(path, 0o600); } catch { /* best-effort */ }
253
261
  try {
254
262
  db.exec(SCHEMA);
263
+ // Columns added after the table first shipped. ALTER fails harmlessly when
264
+ // the column is already there.
265
+ for (const col of ["delivered_answer TEXT", "delivered_cursor TEXT"]) {
266
+ try { db.exec(`ALTER TABLE sent ADD COLUMN ${col}`); } catch { /* exists */ }
267
+ }
255
268
  return db;
256
269
  } catch {
257
270
  try { db.close(); } catch { /* ignore */ }
@@ -354,3 +367,94 @@ export function parseSince(s: string, now = Date.now()): number | undefined {
354
367
  const t = Date.parse(s);
355
368
  return Number.isNaN(t) ? undefined : t;
356
369
  }
370
+
371
+ // --- ask collection ----------------------------------------------------------
372
+
373
+ /** Run one bookkeeping UPDATE against an EXISTING log. Never creates the file
374
+ * (no log means no row to update) and never throws. */
375
+ function updateLog(sql: string, ...params: unknown[]): void {
376
+ if (!attributionEnabled() || !existsSync(sentDbPath())) return;
377
+ const db = openSentLog();
378
+ if (!db) return;
379
+ try {
380
+ db.prepare(sql).run(...params);
381
+ } catch {
382
+ // bookkeeping only
383
+ } finally {
384
+ try { db.close(); } catch { /* ignore */ }
385
+ }
386
+ }
387
+
388
+ /** The caller of `--wait`/`--waitFor` got the DECISION (exit 0): the ask is
389
+ * collected and drops off `ask --pending`. A no-op when the ask is not in this
390
+ * machine's log (asked elsewhere). */
391
+ export function markAskCollected(channel: string, ts: string, answer: string, cursor: string): void {
392
+ const now = Date.now();
393
+ updateLog(
394
+ `UPDATE sent SET collected_at = COALESCE(collected_at, ?), answer = ?, answer_exit = 0,
395
+ delivered_at = ?, delivered_answer = ?, delivered_cursor = ?
396
+ WHERE kind = 'ask' AND channel = ? AND ts = ?`,
397
+ now, answer, now, answer, cursor, channel, ts,
398
+ );
399
+ }
400
+
401
+ /** The caller got a free-text reply (exit 5) — delivered, but NOT a decision,
402
+ * so the ask stays uncollected: a pill pressed later is still the answer. */
403
+ export function markAskDelivered(channel: string, ts: string, answer: string, cursor: string): void {
404
+ updateLog(
405
+ `UPDATE sent SET delivered_at = ?, delivered_answer = ?, delivered_cursor = ?
406
+ WHERE kind = 'ask' AND channel = ? AND ts = ? AND collected_at IS NULL`,
407
+ Date.now(), answer, cursor, channel, ts,
408
+ );
409
+ }
410
+
411
+ /** Logged asks nobody has collected yet, oldest first. `undefined` when the
412
+ * log cannot be opened at all (no SQLite in this runtime). */
413
+ export function uncollectedAsks(sinceMs: number): SentRow[] | undefined {
414
+ if (!existsSync(sentDbPath())) return [];
415
+ const db = openSentLog();
416
+ if (!db) return undefined;
417
+ try {
418
+ return db.prepare(
419
+ `SELECT * FROM sent WHERE kind = 'ask' AND collected_at IS NULL AND sent_at >= ? ORDER BY sent_at ASC, id ASC`,
420
+ ).all(sinceMs) as SentRow[];
421
+ } finally {
422
+ try { db.close(); } catch { /* ignore */ }
423
+ }
424
+ }
425
+
426
+ /** Is the agent that sent this row still the process at that pid? A pid alone
427
+ * is not enough — pids are reused, and telling someone "the asker is still
428
+ * running" about whatever now holds the number would send them to the wrong
429
+ * agent. So the process must also (a) have started before the message was
430
+ * sent, and (b) carry the process name of the recorded CLI, when that CLI is
431
+ * one we know by name. */
432
+ export function senderAlive(row: Pick<SentRow, "agent_pid" | "pid" | "cli" | "sent_at">): boolean {
433
+ const pid = row.agent_pid ?? row.pid;
434
+ if (!pidAlive(pid)) return false;
435
+ try {
436
+ const r = spawnSync("ps", ["-o", "lstart=,comm=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 });
437
+ const line = (r.stdout ?? "").trim();
438
+ // lstart is a fixed 24-char date ("Sun Sep 27 10:00:00 2026"), comm follows.
439
+ const started = Date.parse(line.slice(0, 24));
440
+ if (!Number.isNaN(started) && started > row.sent_at + 1000) return false;
441
+ const comm = basename(line.slice(24).trim());
442
+ const known = new Set(Object.values(AGENT_PROCESS_NAMES));
443
+ if (row.cli && known.has(row.cli) && comm && AGENT_PROCESS_NAMES[comm] !== row.cli) return false;
444
+ } catch {
445
+ // ps unavailable: fall back to the bare pid check
446
+ }
447
+ return true;
448
+ }
449
+
450
+ /** Is this pid still running? `kill(pid, 0)` checks without signalling;
451
+ * EPERM means it exists but belongs to someone else — still alive. */
452
+ export function pidAlive(pid: number | null | undefined): boolean {
453
+ if (!pid || pid <= 0) return false;
454
+ try {
455
+ process.kill(pid, 0);
456
+ return true;
457
+ } catch (e: unknown) {
458
+ return (e as { code?: string }).code === "EPERM";
459
+ }
460
+ }
package/ts/slack.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Slack Web API client (user token, Authorization: Bearer)
2
2
  import { resolveCookie } from "./profiles.ts";
3
3
  import { linkifyRichText } from "./urlGuard.ts";
4
+ import { repairMessageText } from "./newlines.ts";
4
5
 
5
6
  export class RateLimitError extends Error {
6
7
  retryAfter: number;
@@ -248,7 +249,21 @@ export async function history(
248
249
  if (oldest !== undefined) params.oldest = oldest;
249
250
  if (cursor !== undefined) params.cursor = cursor;
250
251
  if (inclusive) params.inclusive = "true";
251
- return get(token, "conversations.history", params, cookie);
252
+ return repairMessages(await get(token, "conversations.history", params, cookie));
253
+ }
254
+
255
+ /** Give every message in a history/replies/search response its line breaks
256
+ * back when Slack stored its text flattened (see newlines.ts). Done here, at
257
+ * the fetch, so `read`, `--format jsonl`, `ask`'s parser and every other
258
+ * reader of `.text` agree on one text. */
259
+ function repairMessages(resp: Json): Json {
260
+ const r = resp as { messages?: Json };
261
+ const msgs = Array.isArray(r.messages) ? r.messages
262
+ : r.messages && typeof r.messages === "object" && Array.isArray((r.messages as { matches?: Json }).matches)
263
+ ? (r.messages as { matches: Json[] }).matches
264
+ : [];
265
+ for (const m of msgs) repairMessageText(m);
266
+ return resp;
252
267
  }
253
268
 
254
269
  export async function replies(
@@ -258,7 +273,40 @@ export async function replies(
258
273
  limit = 50,
259
274
  cookie?: string,
260
275
  ): Promise<Json> {
261
- return get(token, "conversations.replies", { channel, ts, limit: String(limit) }, cookie);
276
+ return repairMessages(await get(token, "conversations.replies", { channel, ts, limit: String(limit) }, cookie));
277
+ }
278
+
279
+ // One page of a thread's replies newer than `oldest` (exclusive). Slack returns
280
+ // the parent first on every page regardless of `oldest`; callers drop it.
281
+ export async function repliesPage(
282
+ token: string,
283
+ channel: string,
284
+ ts: string,
285
+ opts: { oldest?: string; cursor?: string; limit?: number } = {},
286
+ cookie?: string,
287
+ ): Promise<Json> {
288
+ const params: Record<string, string> = { channel, ts, limit: String(opts.limit ?? 200) };
289
+ if (opts.oldest !== undefined) params.oldest = opts.oldest;
290
+ if (opts.cursor !== undefined) params.cursor = opts.cursor;
291
+ return repairMessages(await get(token, "conversations.replies", params, cookie));
292
+ }
293
+
294
+ // Conversations the token's OWN identity is a member of (users.conversations),
295
+ // unlike conversations.list, which also returns public channels it is not in.
296
+ export async function userConversations(token: string, types: string, cookie?: string): Promise<Json[]> {
297
+ const out: Json[] = [];
298
+ let cursor = "";
299
+ do {
300
+ const params: Record<string, string> = { limit: "200", types, exclude_archived: "true" };
301
+ if (cursor) params.cursor = cursor;
302
+ const resp = (await get(token, "users.conversations", params, cookie)) as {
303
+ channels?: Json[];
304
+ response_metadata?: { next_cursor?: string };
305
+ };
306
+ out.push(...(resp.channels ?? []));
307
+ cursor = resp.response_metadata?.next_cursor ?? "";
308
+ } while (cursor);
309
+ return out;
262
310
  }
263
311
 
264
312
  // Metadata for a single uploaded file (files.info). Carries url_private_download,
@@ -368,13 +416,13 @@ export async function searchPage(
368
416
  page: number,
369
417
  cookie?: string,
370
418
  ): Promise<Json> {
371
- return get(token, "search.messages", {
419
+ return repairMessages(await get(token, "search.messages", {
372
420
  query,
373
421
  sort: "timestamp",
374
422
  sort_dir: "desc",
375
423
  count: String(Math.min(Math.max(count, 1), 100)),
376
424
  page: String(Math.max(page, 1)),
377
- }, cookie);
425
+ }, cookie));
378
426
  }
379
427
 
380
428
  export async function search(token: string, query: string, cookie?: string): Promise<Json> {