slack-term 1.31.4 → 1.31.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.31.4",
3
+ "version": "1.31.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/cli.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  pollParseMessage,
39
39
  pollTally,
40
40
  } from "./poll.ts";
41
+ import { seedReactionsInOrder } from "./reactionSeed.ts";
41
42
  import {
42
43
  authTest,
43
44
  authScopes,
@@ -2133,25 +2134,21 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2133
2134
  // NOTE the colons: the modifier's argument is itself colon-wrapped, and the
2134
2135
  // bare `has:question` form does NOT error — it silently degrades to a
2135
2136
  // full-text search and returns plausible-looking counts. See ts/todo.ts.
2136
- try {
2137
- await reactionAdd(token, channelId, ts, ASK_MARKER, cookie);
2138
- } catch (e: unknown) {
2139
- // Cosmetic + searchability, not correctness: the body still identifies it.
2140
- console.error(` (marker ${ASK_MARKER} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`);
2141
- }
2142
-
2143
- // Seeded one at a time and in order: Slack keeps reactions in the order they
2144
- // were added, so sequential adds are what make the pills read 1,2,3 rather
2145
- // than shuffled. A parallel add is not an optimization here, it is a bug.
2146
- for (let i = 0; i < reactable.length; i++) {
2147
- try {
2148
- await reactionAdd(token, channelId, ts, ASK_KEYCAPS[i]!.name, cookie);
2149
- } catch (e: unknown) {
2150
- // A missing seed only costs the answerer a trip to the emoji picker, so
2151
- // warn and keep going rather than abandon a question already posted.
2152
- console.error(` (リアクション ${ASK_KEYCAPS[i]!.name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`);
2153
- }
2154
- }
2137
+ // Marker first, then 1,2,3 — and spaced out. Sequential awaits alone are NOT
2138
+ // enough: measured on two real questions from the same build, one came back
2139
+ // `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
2140
+ // SLACK_REACTION_SEED_GAP_MS.
2141
+ await seedReactionsInOrder(
2142
+ [ASK_MARKER, ...reactable.map((_, i) => ASK_KEYCAPS[i]!.name)],
2143
+ (name) => reactionAdd(token, channelId, ts, name, cookie),
2144
+ (name, e) => {
2145
+ const why = e instanceof Error ? e.message : String(e);
2146
+ // The marker is cosmetic + searchability; a keycap only costs the
2147
+ // answerer a trip to the emoji picker. Neither abandons a posted question.
2148
+ if (name === ASK_MARKER) console.error(` (marker ${name} を付けられませんでした: ${why})`);
2149
+ else console.error(` (リアクション ${name} を付けられませんでした: ${why})`);
2150
+ },
2151
+ );
2155
2152
 
2156
2153
  let permalink = "";
2157
2154
  try {
@@ -2453,24 +2450,19 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2453
2450
  // `has::ballot_box_with_ballot:` list every poll. An unrelated reaction on the message is
2454
2451
  // already ignored by the tally (it counts keycap names only), so this costs
2455
2452
  // the ballot nothing.
2456
- try {
2457
- await reactionAdd(token, channelId, ts, POLL_MARKER, cookie);
2458
- } catch (e: unknown) {
2459
- console.error(` (marker ${POLL_MARKER} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`);
2460
- }
2461
-
2462
- // Sequential and in order: Slack keeps reactions in the order they were added,
2463
- // so this is what makes the pills read 1,2,3 rather than shuffled. Parallel
2464
- // adds are not an optimization here, they are a bug.
2465
- for (let i = 0; i < choices.length; i++) {
2466
- try {
2467
- await reactionAdd(token, channelId, ts, POLL_KEYCAPS[i]!.name, cookie);
2468
- } catch (e: unknown) {
2453
+ // Marker first, then 1,2,3 — and spaced out, for the reason `ask` documents
2454
+ // at its own seeding call and ts/reactionSeed.ts documents in full.
2455
+ await seedReactionsInOrder(
2456
+ [POLL_MARKER, ...choices.map((_, i) => POLL_KEYCAPS[i]!.name)],
2457
+ (name) => reactionAdd(token, channelId, ts, name, cookie),
2458
+ (name, e) => {
2459
+ const why = e instanceof Error ? e.message : String(e);
2469
2460
  // A missing seed costs a voter a trip to the emoji picker; it does not
2470
2461
  // invalidate a poll that is already posted.
2471
- console.error(` (リアクション ${POLL_KEYCAPS[i]!.name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`);
2472
- }
2473
- }
2462
+ if (name === POLL_MARKER) console.error(` (marker ${name} を付けられませんでした: ${why})`);
2463
+ else console.error(` (リアクション ${name} を付けられませんでした: ${why})`);
2464
+ },
2465
+ );
2474
2466
 
2475
2467
  let permalink = "";
2476
2468
  try {
@@ -3325,17 +3317,23 @@ async function main(): Promise<void> {
3325
3317
  (y2) => y2.positional("user", { type: "string", demandOption: true, describe: "@handle or user ID" }),
3326
3318
  async (argv) => {
3327
3319
  const token = tok(argv as W);
3328
- const ref = argv.user!.startsWith("@") ? argv.user!.slice(1) : argv.user!;
3329
- // If not a Slack user ID (U + alphanumeric), resolve from users list by handle
3330
- let userId = ref;
3331
- if (!/^U[A-Z0-9]+$/.test(ref)) {
3332
- const listResp = (await listUsers(token, ck(argv as W))) as Record<string, Json>;
3333
- const match = asArray(listResp.members).map(asRecord)
3334
- .find((u) => String(u.name ?? "") === ref);
3335
- if (!match) { console.error(`User not found: @${ref}`); process.exit(1); }
3336
- userId = String(match.id);
3320
+ const cookie = ck(argv as W);
3321
+ const ref = argv.user!.startsWith("@") ? argv.user! : `@${argv.user!}`;
3322
+ // resolveUserId owns every population: the id form, self, the
3323
+ // workspace roster, and the Slack Connect partners users.list cannot
3324
+ // see. Matching `u.name` against this list by hand — as this used to
3325
+ // — missed display names, real names, and everyone from another team.
3326
+ let userId = "";
3327
+ try {
3328
+ userId = await resolveUserId(token, ref, cookie);
3329
+ } catch (e) {
3330
+ console.error(e instanceof Error ? e.message : String(e));
3331
+ process.exit(1);
3337
3332
  }
3338
- const resp = asRecord((await userInfo(token, userId)) as Json);
3333
+ // The cookie is not optional here: on a desktop (xoxc-) token the
3334
+ // public Web API answers invalid_auth without it, which read as
3335
+ // "your token is wrong" on a lookup that was about to succeed.
3336
+ const resp = asRecord((await userInfo(token, userId, cookie)) as Json);
3339
3337
  const u = asRecord(resp.user);
3340
3338
  const profile = asRecord(u.profile);
3341
3339
  const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -0,0 +1,82 @@
1
+ // Seeding the answer pills on an `ask` / `poll` message, in order.
2
+ //
3
+ // MEASURED, 2026-08-31, two real questions posted five seconds apart by the
4
+ // SAME build:
5
+ //
6
+ // 07:42:31 two | one | question | three ← shuffled
7
+ // 07:42:36 question | one | two | three | four ← correct
8
+ //
9
+ // The adds have been sequential (`await` inside a `for`) since the first commit
10
+ // of `ask`, and there has never been a `Promise.all` here — so the reordering
11
+ // happens on Slack's side, not ours. The comments that used to sit at both call
12
+ // sites ("Slack keeps reactions in the order they were added, so sequential
13
+ // adds are what make the pills read 1,2,3") asserted a guarantee that does not
14
+ // exist, and the shuffled message above is the counter-example.
15
+ //
16
+ // The working theory is that the display order comes from a reaction timestamp
17
+ // stored at second resolution: adds landing inside one second are tie-broken
18
+ // arbitrarily, while adds that straddle a boundary keep their order. That fits
19
+ // both samples — the slower message came out right — and it is why the gap
20
+ // below is just over a second rather than a token 100ms.
21
+ //
22
+ // It is a THEORY. Confirming it needs `reactions.add` against a real workspace,
23
+ // which this repo's QA rule forbids (see CLAUDE.md: read-only only), and Slack
24
+ // exposes no timestamp on a reaction to measure it from. So the gap is tunable
25
+ // without a code change: set SLACK_REACTION_SEED_GAP_MS, and 0 restores the old
26
+ // back-to-back behaviour.
27
+
28
+ /** Just over one second, so two consecutive seeds cannot share a second. */
29
+ export const DEFAULT_SEED_GAP_MS = 1100;
30
+
31
+ /** True under vitest or `bun test`. Same check as ts/todo.ts, and for the same
32
+ * reason: a seam that only tests replace gets forgotten, and then the suite
33
+ * waits real seconds per seeded reaction.
34
+ *
35
+ * It takes `env` so it can be tested by VALUE. Testing it by deleting
36
+ * NODE_ENV/VITEST from `process.env` and putting them back is what a first
37
+ * version did, and under `bun test --parallel=2` the deletion was visible to
38
+ * every CLI subprocess another test file spawned in that window: the suite
39
+ * went from 45s to 169s because those children then waited the real gap. */
40
+ export function isTestEnv(env: NodeJS.ProcessEnv = process.env): boolean {
41
+ return !!env.VITEST || env.NODE_ENV === "test" || !!env.BUN_TEST;
42
+ }
43
+
44
+ export const _internals = {
45
+ isTestEnv: (): boolean => isTestEnv(),
46
+ sleep: (ms: number): Promise<void> =>
47
+ _internals.isTestEnv() ? Promise.resolve() : new Promise((res) => setTimeout(res, ms)),
48
+ };
49
+
50
+ /** The configured gap. Invalid values fall back to the default rather than
51
+ * disabling the gap silently — a typo must not quietly restore the bug. */
52
+ export function seedGapMs(env: NodeJS.ProcessEnv = process.env): number {
53
+ const raw = env.SLACK_REACTION_SEED_GAP_MS;
54
+ if (raw === undefined || raw === "") return DEFAULT_SEED_GAP_MS;
55
+ const n = Number(raw);
56
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_SEED_GAP_MS;
57
+ return n;
58
+ }
59
+
60
+ /** Add `names` one at a time, in order, pausing between them.
61
+ *
62
+ * A failed seed is reported and skipped, never fatal: the message is already
63
+ * posted, and a missing pill costs the answerer a trip to the emoji picker.
64
+ * The pause is skipped after the last add, and after one that failed — there
65
+ * is nothing left to keep in order. */
66
+ export async function seedReactionsInOrder(
67
+ names: readonly string[],
68
+ add: (name: string) => Promise<void>,
69
+ onError: (name: string, e: unknown) => void,
70
+ gapMs: number = seedGapMs(),
71
+ ): Promise<void> {
72
+ for (let i = 0; i < names.length; i++) {
73
+ let ok = true;
74
+ try {
75
+ await add(names[i]!);
76
+ } catch (e: unknown) {
77
+ ok = false;
78
+ onError(names[i]!, e);
79
+ }
80
+ if (ok && gapMs > 0 && i < names.length - 1) await _internals.sleep(gapMs);
81
+ }
82
+ }
package/ts/slack.ts CHANGED
@@ -501,46 +501,215 @@ export async function openDm(token: string, userId: string, cookie?: string): Pr
501
501
  return id;
502
502
  }
503
503
 
504
+ /** The existing IM with `userId`, or "" when there is none. Paginated: a
505
+ * workspace with many DMs pages its im list like any other conversation, and
506
+ * reading only the first page is how a DM that exists reads as missing. */
507
+ async function findExistingDm(token: string, userId: string, cookie?: string): Promise<string> {
508
+ let cursor = "";
509
+ while (true) {
510
+ const params: Record<string, string> = { types: "im", limit: "200" };
511
+ if (cursor) params.cursor = cursor;
512
+ const resp = (await get(token, "conversations.list", params, cookie)) as {
513
+ channels?: Array<{ id?: string; user?: string }>;
514
+ response_metadata?: { next_cursor?: string };
515
+ };
516
+ for (const ch of resp.channels ?? []) {
517
+ if (ch.user === userId) return String(ch.id ?? "");
518
+ }
519
+ cursor = resp.response_metadata?.next_cursor ?? "";
520
+ if (!cursor) break;
521
+ }
522
+ return "";
523
+ }
524
+
525
+ /** The DM channel with a known user id. Read-only on purpose: opening a DM
526
+ * needs im:write, which plenty of read tokens do not carry, so an absent DM is
527
+ * reported rather than created. */
528
+ async function dmWithUser(token: string, userId: string, cookie?: string, label?: string): Promise<string> {
529
+ const dm = await findExistingDm(token, userId, cookie);
530
+ if (dm) return dm;
531
+ const who = label ? `${label} (${userId})` : userId;
532
+ throw new Error(`No existing DM with ${who}. Open it once in Slack first.`);
533
+ }
534
+
504
535
  /** Normalize for loose matching: lowercase + strip hyphens/underscores/whitespace.
505
536
  * Lets `@deploy-bot` match handles like `deploybot` or display names like `Deploy-Bot`. */
506
537
  function normName(s: string): string {
507
538
  return s.toLowerCase().replace(/[-_\s]/g, "");
508
539
  }
509
540
 
510
- /**
511
- * Resolve `@name` (or a raw U…/W… id) to a user ID. Used when DMing as a bot:
512
- * the lookup runs on a *user* token (which has users:read), then the caller
513
- * opens the DM with the bot token — so the bot never needs users:read.
514
- */
515
- export async function resolveUserId(token: string, ref: string, cookie?: string): Promise<string> {
516
- if (!ref.startsWith("@")) {
517
- if (/^[UW][A-Za-z0-9]{6,}$/.test(ref)) return ref;
518
- throw new Error(`Expected @user or a user ID, got: ${ref}`);
519
- }
520
- const nameNorm = normName(ref.slice(1));
521
- const selfInfo = (await get(token, "auth.test", {}, cookie)) as { user_id?: string; user?: string };
522
- if (nameNorm === "you" || nameNorm === "me" || normName(selfInfo.user ?? "") === nameNorm) {
523
- if (!selfInfo.user_id) throw new Error("auth.test did not return user_id");
524
- return selfInfo.user_id;
525
- }
541
+ type Member = {
542
+ id?: string;
543
+ name?: string;
544
+ real_name?: string;
545
+ profile?: { display_name?: string; real_name?: string };
546
+ };
547
+
548
+ function memberMatches(u: Member, nameNorm: string): boolean {
549
+ const n = normName(u.name ?? "");
550
+ const rn = normName(u.real_name ?? u.profile?.real_name ?? "");
551
+ const dn = normName(u.profile?.display_name ?? "");
552
+ return n === nameNorm || rn === nameNorm || (!!dn && dn === nameNorm);
553
+ }
554
+
555
+ /** What a `@name` lookup found, and — when it found nothing — which populations
556
+ * it actually searched. The counts are the whole point: "not found" out of 40
557
+ * people is a wrong handle, "not found" out of 0 is a broken token, and the
558
+ * caller cannot tell those apart from the sentence alone. */
559
+ type UserSearch = {
560
+ /** "" when nothing matched. */
561
+ id: string;
562
+ /** The existing IM with them, when the match came from the DM scan. */
563
+ dm?: string;
564
+ workspaceCount: number;
565
+ connectCount: number;
566
+ /** DM partners whose profile could not be read, so they were never compared. */
567
+ skipped: number;
568
+ /** True when the scan stopped at CONNECT_SCAN_LIMIT with partners left over. */
569
+ capped: boolean;
570
+ };
571
+
572
+ /** How many non-roster DM partners a failed lookup will resolve one at a time.
573
+ * Each costs a `users.info`, so an account with thousands of Slack Connect DMs
574
+ * would otherwise turn a single typo into thousands of sequential calls and
575
+ * rate-limit the caller out of the API. Past the cap the scan stops and says it
576
+ * stopped — an incomplete answer that admits it beats a complete-looking one. */
577
+ const CONNECT_SCAN_LIMIT = 200;
578
+
579
+ /** Search every population this token can see for `@name`.
580
+ *
581
+ * `users.list` is scoped to YOUR workspace, so a Slack Connect counterpart —
582
+ * who belongs to another team entirely — is never in it, no matter what
583
+ * parameters the call is given. The only place they exist is the membership of
584
+ * a conversation you share, and for a person you DM the cheapest such place is
585
+ * your own IM list: it costs one `users.info` per external DM partner and hands
586
+ * back the channel id the caller was about to look up anyway. */
587
+ async function searchUsers(token: string, nameNorm: string, cookie?: string): Promise<UserSearch> {
588
+ let workspaceCount = 0;
589
+ const known = new Set<string>();
526
590
  let cursor = "";
527
591
  while (true) {
528
592
  const params: Record<string, string> = { limit: "200" };
529
593
  if (cursor) params.cursor = cursor;
530
594
  const resp = (await get(token, "users.list", params, cookie)) as {
531
- members?: Array<{ id?: string; name?: string; real_name?: string; profile?: { display_name?: string } }>;
595
+ members?: Member[];
532
596
  response_metadata?: { next_cursor?: string };
533
597
  };
534
598
  for (const u of resp.members ?? []) {
535
- const n = normName(u.name ?? "");
536
- const rn = normName(u.real_name ?? "");
537
- const dn = normName(u.profile?.display_name ?? "");
538
- if (n === nameNorm || rn === nameNorm || (dn && dn === nameNorm)) return u.id ?? "";
599
+ workspaceCount++;
600
+ if (u.id) known.add(u.id);
601
+ if (memberMatches(u, nameNorm)) {
602
+ return { id: u.id ?? "", workspaceCount, connectCount: 0, skipped: 0, capped: false };
603
+ }
539
604
  }
540
605
  cursor = resp.response_metadata?.next_cursor ?? "";
541
606
  if (!cursor) break;
542
607
  }
543
- throw new Error(`User not found: ${ref}`);
608
+
609
+ let connectCount = 0;
610
+ let skipped = 0;
611
+ let capped = false;
612
+ let dmCursor = "";
613
+ while (true) {
614
+ const params: Record<string, string> = { types: "im", limit: "200" };
615
+ if (dmCursor) params.cursor = dmCursor;
616
+ const resp = (await get(token, "conversations.list", params, cookie)) as {
617
+ channels?: Array<{ id?: string; user?: string }>;
618
+ response_metadata?: { next_cursor?: string };
619
+ };
620
+ for (const ch of resp.channels ?? []) {
621
+ const uid = ch.user;
622
+ if (!uid || known.has(uid)) continue;
623
+ if (connectCount >= CONNECT_SCAN_LIMIT) {
624
+ capped = true;
625
+ break;
626
+ }
627
+ known.add(uid);
628
+ connectCount++;
629
+ let u: Member | undefined;
630
+ try {
631
+ u = ((await get(token, "users.info", { user: uid }, cookie)) as { user?: Member }).user;
632
+ } catch (e) {
633
+ // A rate limit is not "this person does not match" — it is the scan
634
+ // being cut off mid-way, and swallowing it is precisely how a lookup
635
+ // that could not see its population ends up reporting absence. Let it
636
+ // out. Any other per-profile failure costs us that one person, and is
637
+ // counted so the error can admit the search was incomplete.
638
+ if (e instanceof RateLimitError) throw e;
639
+ skipped++;
640
+ continue;
641
+ }
642
+ if (u && memberMatches(u, nameNorm)) {
643
+ return { id: uid, dm: String(ch.id ?? ""), workspaceCount, connectCount, skipped, capped };
644
+ }
645
+ }
646
+ if (capped) break;
647
+ dmCursor = resp.response_metadata?.next_cursor ?? "";
648
+ if (!dmCursor) break;
649
+ }
650
+ return { id: "", workspaceCount, connectCount, skipped, capped };
651
+ }
652
+
653
+ /** The error for a lookup that came back empty. An empty roster and a wrong
654
+ * handle must not share a sentence: the first is a token or scope fault the
655
+ * caller can fix, the second is a typo, and "User not found" told both stories
656
+ * at once — so a lookup that could not see its population reported absence.
657
+ *
658
+ * "account(s)", not "member(s)", and the count will not match `user ls`: that
659
+ * command hides deleted accounts and bots, and this counts the raw users.list
660
+ * the search actually walked. The two differ by design — the number here is a
661
+ * report on the search, not a headcount of the workspace. */
662
+ function userNotFoundError(ref: string, found: UserSearch): Error {
663
+ if (found.workspaceCount === 0) {
664
+ return new Error(
665
+ `Cannot resolve ${ref}: users.list returned no members at all, so nothing could have matched.\n` +
666
+ `That is a token or scope fault, not a wrong handle — this says nothing about whether ${ref} exists.\n` +
667
+ ` Check who you are: slack auth ls`,
668
+ );
669
+ }
670
+ const searched = found.connectCount
671
+ ? `${found.workspaceCount} workspace account(s) and ${found.connectCount} Slack Connect DM partner(s)`
672
+ : `${found.workspaceCount} workspace account(s)`;
673
+ // Anything the scan did not actually look at is said out loud. "None matched"
674
+ // is only true of what was compared, and the gap between that and "not there"
675
+ // is the whole bug this function exists to stop repeating.
676
+ const gaps: string[] = [];
677
+ if (found.capped) {
678
+ gaps.push(`stopped after ${CONNECT_SCAN_LIMIT} Slack Connect DM partners, so there may be more`);
679
+ }
680
+ if (found.skipped) {
681
+ gaps.push(`${found.skipped} DM partner profile(s) could not be read and were never compared`);
682
+ }
683
+ return new Error(
684
+ `User not found: ${ref} — searched ${searched}, none matched.\n` +
685
+ (gaps.length ? `This search was incomplete: ${gaps.join("; ")}.\n` : "") +
686
+ `Slack Connect people from another workspace are not in users.list, so if they never\n` +
687
+ `DMed you they cannot be found by name at all. Look them up and use their ID:\n` +
688
+ ` slack user ls --external`,
689
+ );
690
+ }
691
+
692
+ /**
693
+ * Resolve `@name` (or a raw U…/W… id) to a user ID. Used when DMing as a bot:
694
+ * the lookup runs on a *user* token (which has users:read), then the caller
695
+ * opens the DM with the bot token — so the bot never needs users:read.
696
+ */
697
+ export async function resolveUserId(token: string, ref: string, cookie?: string): Promise<string> {
698
+ // `@U0123456789` — an id wearing the `@` that every other user target takes.
699
+ // Nobody is NAMED that, so the name walk below would end in "user not found"
700
+ // and point at the wrong problem: the user is there, only the `@` is not.
701
+ const bare = ref.startsWith("@") ? ref.slice(1) : ref;
702
+ if (isUserId(bare)) return bare;
703
+ if (!ref.startsWith("@")) throw new Error(`Expected @user or a user ID, got: ${ref}`);
704
+ const nameNorm = normName(bare);
705
+ const selfInfo = (await get(token, "auth.test", {}, cookie)) as { user_id?: string; user?: string };
706
+ if (nameNorm === "you" || nameNorm === "me" || normName(selfInfo.user ?? "") === nameNorm) {
707
+ if (!selfInfo.user_id) throw new Error("auth.test did not return user_id");
708
+ return selfInfo.user_id;
709
+ }
710
+ const found = await searchUsers(token, nameNorm, cookie);
711
+ if (found.id) return found.id;
712
+ throw userNotFoundError(ref, found);
544
713
  }
545
714
 
546
715
  /** Parse a Slack permalink, returning channel ID, optional message ts, and optional thread_ts.
@@ -576,6 +745,13 @@ function isChannelId(s: string): boolean {
576
745
  return /^[CDG][A-Z0-9]{8,}$/.test(s);
577
746
  }
578
747
 
748
+ /** A Slack user ID: `U…` (member) or `W…` (Enterprise Grid member). Upper-case
749
+ * only — that is what keeps a lowercase handle like `@u1234567` reading as a
750
+ * name instead of being swallowed by the id branch. */
751
+ function isUserId(s: string): boolean {
752
+ return /^[UW][A-Z0-9]{6,}$/.test(s);
753
+ }
754
+
579
755
  export async function resolveChannel(token: string, ref: string, cookie?: string): Promise<string> {
580
756
  // Accept Slack permalinks directly
581
757
  const fromUrl = parseSlackUrl(ref);
@@ -583,6 +759,10 @@ export async function resolveChannel(token: string, ref: string, cookie?: string
583
759
  // Accept raw IDs (C..., D..., G...) as-is
584
760
  if (!ref.startsWith("@") && !ref.startsWith("#")) {
585
761
  if (isChannelId(ref)) return ref;
762
+ // A bare `U…` names a person, and the conversation meant by a person is the
763
+ // DM with them. Refusing it as a malformed target sent callers hunting for a
764
+ // syntax error when what they had handed over was a perfectly good user.
765
+ if (isUserId(ref)) return dmWithUser(token, ref, cookie);
586
766
  throw new Error(`Target must start with # or @ (or be a Slack URL/ID), got: ${ref}`);
587
767
  }
588
768
  // `#C0123456789` — an ID that was given the `#` every other target form takes.
@@ -596,6 +776,9 @@ export async function resolveChannel(token: string, ref: string, cookie?: string
596
776
  const nameNorm = normName(rawName);
597
777
 
598
778
  if (isIm) {
779
+ // `@U0123456789` — the id form wearing the `@`, same reasoning as the bare
780
+ // id above: resolve it as a person, not as a name nobody has.
781
+ if (isUserId(rawName)) return dmWithUser(token, rawName, cookie);
599
782
  // Use auth.test to check if @name refers to self (avoids users:read scope requirement).
600
783
  // auth.test is always available; users.list requires users:read which xoxc- tokens lack.
601
784
  const selfInfo = (await get(token, "auth.test", {}, cookie)) as { user_id?: string; user?: string };
@@ -603,55 +786,16 @@ export async function resolveChannel(token: string, ref: string, cookie?: string
603
786
  if (isSelf) {
604
787
  const userId = selfInfo.user_id;
605
788
  if (!userId) throw new Error("auth.test did not return user_id");
606
- const dmResp = (await get(token, "conversations.list", { types: "im", limit: "200" }, cookie)) as {
607
- channels?: Array<{ id?: string; user?: string }>;
608
- };
609
- const dm = (dmResp.channels ?? []).find((ch) => ch.user === userId);
610
- if (dm?.id) return String(dm.id);
611
- return openDm(token, userId, cookie);
789
+ const dm = await findExistingDm(token, userId, cookie);
790
+ return dm || openDm(token, userId, cookie);
612
791
  }
613
792
 
614
- // Find user ID first via users.list (batch), then locate existing DM.
615
- let userId = "";
616
- let userCursor = "";
617
- while (true) {
618
- const params: Record<string, string> = { limit: "200" };
619
- if (userCursor) params.cursor = userCursor;
620
- const resp = (await get(token, "users.list", params, cookie)) as {
621
- members?: Array<{ id?: string; name?: string; real_name?: string; profile?: { display_name?: string } }>;
622
- response_metadata?: { next_cursor?: string };
623
- };
624
- for (const u of resp.members ?? []) {
625
- const n = normName(u.name ?? "");
626
- const rn = normName(u.real_name ?? "");
627
- const dn = normName(u.profile?.display_name ?? "");
628
- if (n === nameNorm || rn === nameNorm || (dn && dn === nameNorm)) {
629
- userId = u.id ?? "";
630
- break;
631
- }
632
- }
633
- if (userId) break;
634
- userCursor = resp.response_metadata?.next_cursor ?? "";
635
- if (!userCursor) break;
636
- }
637
- if (!userId) throw new Error(`User not found: ${ref}`);
638
-
639
- // Find an existing DM (avoids needing im:write scope)
640
- let dmCursor = "";
641
- while (true) {
642
- const params: Record<string, string> = { types: "im", limit: "200" };
643
- if (dmCursor) params.cursor = dmCursor;
644
- const resp = (await get(token, "conversations.list", params, cookie)) as {
645
- channels?: Array<{ id?: string; user?: string }>;
646
- response_metadata?: { next_cursor?: string };
647
- };
648
- for (const ch of resp.channels ?? []) {
649
- if (ch.user === userId) return String(ch.id ?? "");
650
- }
651
- dmCursor = resp.response_metadata?.next_cursor ?? "";
652
- if (!dmCursor) break;
653
- }
654
- throw new Error(`No existing DM with ${ref} (${userId}). Open it once in Slack first.`);
793
+ const found = await searchUsers(token, nameNorm, cookie);
794
+ if (!found.id) throw userNotFoundError(ref, found);
795
+ // The Connect scan already walked the IM list to find them, so it knows the
796
+ // channel — asking again would only pay for the same pages twice.
797
+ if (found.dm) return found.dm;
798
+ return dmWithUser(token, found.id, cookie, ref);
655
799
  }
656
800
 
657
801
  // Channel lookup
package/ts/tail.ts CHANGED
@@ -238,7 +238,9 @@ export async function cmdTail(
238
238
  process.exit(1);
239
239
  }
240
240
 
241
- const channelId = await resolveChannel(token, target);
241
+ // Cookie included: a desktop (xoxc-) token is rejected by the public Web API
242
+ // without it, so dropping it here failed the lookup before the tail started.
243
+ const channelId = await resolveChannel(token, target, opts.cookie);
242
244
  const interval = opts.interval ?? 60000;
243
245
  // Optional auto-stop deadline (e.g. wait at most 30m for a reply).
244
246
  const deadline = opts.timeout ? _internals.now() + parseSince(opts.timeout) * 1000 : Infinity;