slack-term 1.22.0 → 1.23.1

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/cli.ts CHANGED
@@ -8,13 +8,14 @@ import { join } from "node:path";
8
8
 
9
9
  import yargs from "yargs";
10
10
  import { hideBin } from "yargs/helpers";
11
- import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile } from "./profiles.ts";
11
+ import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile, type Profile } from "./profiles.ts";
12
12
  import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
13
13
  import { cmdAuthLogin, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp } from "./auth.ts";
14
14
  import { cmdTail } from "./tail.ts";
15
15
 
16
16
  import {
17
17
  authTest,
18
+ authScopes,
18
19
  authTestSession,
19
20
  conversationInfoSession,
20
21
  createChannel,
@@ -386,8 +387,52 @@ async function cmdChannels(token: string, limit: number, filter?: string, all?:
386
387
  }
387
388
 
388
389
  // --- search ---
389
- async function cmdSearch(token: string, query: string, count: number, json: boolean): Promise<void> {
390
- const resp = await searchAll(token, query, count);
390
+
391
+ /** search.messages (and most read/write Web API methods) reject a bot token
392
+ * (xoxb-) with not_allowed_token_type — it's a user-only endpoint. Find a
393
+ * sibling profile that holds a user token (xoxp-/xoxc-) for the *same*
394
+ * workspace (teamId) as the bot profile currently in use, so the caller can
395
+ * retry with it automatically instead of just erroring. Deliberately does
396
+ * NOT fall back to a user-token profile for a different workspace — that
397
+ * would silently run the search against the wrong (possibly private)
398
+ * workspace instead of failing loudly. Returns undefined (no fallback) when
399
+ * the current token isn't a known profile at all, since its workspace can't
400
+ * be verified. */
401
+ function findUserTokenProfile(currentToken: string): { name: string; profile: Profile } | undefined {
402
+ const isUserToken = (t: string) => t.startsWith("xoxp-") || t.startsWith("xoxc-");
403
+ const profiles = listProfiles();
404
+ const current = profiles.find((p) => p.profile.token === currentToken);
405
+ if (!current) return undefined;
406
+ return profiles.find((p) => isUserToken(p.profile.token) && p.profile.teamId === current.profile.teamId);
407
+ }
408
+
409
+ async function cmdSearch(token: string, query: string, count: number, json: boolean, cookie?: string): Promise<void> {
410
+ let searchToken = token;
411
+ let searchCookie = cookie;
412
+ let resp: Json;
413
+ try {
414
+ resp = await searchAll(searchToken, query, count, searchCookie);
415
+ } catch (e: unknown) {
416
+ const msg = e instanceof Error ? e.message : String(e);
417
+ if (token.startsWith("xoxb-") && msg.includes("not_allowed_token_type")) {
418
+ const fallback = findUserTokenProfile(token);
419
+ if (fallback) {
420
+ console.error(`Note: search.messages needs a user token — the active profile is a bot token (xoxb-); falling back to profile "${fallback.name}".`);
421
+ searchToken = fallback.profile.token;
422
+ searchCookie = fallback.profile.cookie;
423
+ resp = await searchAll(searchToken, query, count, searchCookie);
424
+ } else {
425
+ console.error(
426
+ "Error: search requires a user token (xoxp-/xoxc-) — the active profile is a bot token (xoxb-), which Slack rejects for search.messages.\n" +
427
+ " Add a user-token profile: slack auth login (or slack auth token)\n" +
428
+ " Then select it: slack auth use <name> (or pass --workspace <name>)",
429
+ );
430
+ process.exit(1);
431
+ }
432
+ } else {
433
+ throw e;
434
+ }
435
+ }
391
436
  if (json) {
392
437
  console.log(JSON.stringify(resp, null, 2));
393
438
  return;
@@ -402,7 +447,7 @@ async function cmdSearch(token: string, query: string, count: number, json: bool
402
447
  if (isIm && rawName.startsWith("U")) {
403
448
  const handleKey = "@" + rawName;
404
449
  if (!cache.has(handleKey)) {
405
- const [, h] = await userInfoPair(token, rawName);
450
+ const [, h] = await userInfoPair(searchToken, rawName, searchCookie);
406
451
  cache.set(handleKey, h);
407
452
  }
408
453
  chLabel = `@${cache.get(handleKey) ?? rawName}`;
@@ -411,7 +456,7 @@ async function cmdSearch(token: string, query: string, count: number, json: bool
411
456
  } else {
412
457
  chLabel = `#${rawName}`;
413
458
  }
414
- console.log(await formatMsgLine(token, m, cache, chLabel));
459
+ console.log(await formatMsgLine(searchToken, m, cache, chLabel));
415
460
  }
416
461
  }
417
462
 
@@ -704,15 +749,15 @@ function parseTargetThread(s: string): { ref: string; threadTs?: string } {
704
749
  /** Human-readable destination label for confirm gates, e.g. "#symval (C0B0L6SSAMD)".
705
750
  * When ref is already a #channel/@user label, keep it; otherwise resolve the channel
706
751
  * name via conversations.info (fail-soft: a raw ID is still unambiguous). */
707
- async function destLabel(token: string, channelId: string, ref: string): Promise<string> {
752
+ async function destLabel(token: string, channelId: string, ref: string, cookie?: string): Promise<string> {
708
753
  let name = ref;
709
754
  if (!ref.startsWith("#") && !ref.startsWith("@")) {
710
755
  try {
711
- const info = asRecord((await conversationInfo(token, channelId)) as Json);
756
+ const info = asRecord((await conversationInfo(token, channelId, cookie)) as Json);
712
757
  const ch = asRecord(info.channel);
713
758
  if (ch.is_im === true) {
714
759
  const uid = typeof ch.user === "string" ? ch.user : "";
715
- name = uid ? `@${await userName(token, uid)}` : channelId;
760
+ name = uid ? `@${await userName(token, uid, cookie)}` : channelId;
716
761
  } else {
717
762
  name = typeof ch.name === "string" ? `#${ch.name}` : channelId;
718
763
  }
@@ -725,9 +770,9 @@ async function destLabel(token: string, channelId: string, ref: string): Promise
725
770
 
726
771
  /** One-line description of a single thread message for confirm gates:
727
772
  * "2026-06-11 08:05 @handle: head…". `headLen` bounds the text preview. */
728
- async function threadMsgLine(token: string, m: Record<string, Json>, headLen = 60): Promise<string> {
773
+ async function threadMsgLine(token: string, m: Record<string, Json>, headLen = 60, cookie?: string): Promise<string> {
729
774
  const author = typeof m.user === "string"
730
- ? await userName(token, m.user)
775
+ ? await userName(token, m.user, cookie)
731
776
  : typeof m.username === "string"
732
777
  ? m.username
733
778
  : "?";
@@ -740,10 +785,10 @@ async function threadMsgLine(token: string, m: Record<string, Json>, headLen = 6
740
785
 
741
786
  /** One-line thread-parent description for confirm gates: "2026-06-11 08:05 @handle: head…".
742
787
  * Fail-soft: returns the raw ts if the parent cannot be found. */
743
- async function threadParentLine(token: string, threadTs: string, threadMsgs: Record<string, Json>[]): Promise<string> {
788
+ async function threadParentLine(token: string, threadTs: string, threadMsgs: Record<string, Json>[], cookie?: string): Promise<string> {
744
789
  const parent = threadMsgs.find((m) => String(m.ts) === threadTs) ?? threadMsgs[0];
745
790
  if (!parent) return threadTs;
746
- return threadMsgLine(token, parent, 40);
791
+ return threadMsgLine(token, parent, 40, cookie);
747
792
  }
748
793
 
749
794
  /** Normalize a message for duplicate comparison: lowercase, collapse whitespace,
@@ -799,6 +844,9 @@ interface EditArgs {
799
844
  code?: string;
800
845
  channelId?: string;
801
846
  mentions?: boolean;
847
+ // Session cookie (xoxd) for `token` — required for an xoxc- desktop session
848
+ // token to be accepted by the public Slack API at all.
849
+ cookie?: string;
802
850
  }
803
851
  async function cmdEdit(token: string, args: EditArgs): Promise<void> {
804
852
  const { ref, ts } = splitRefTs(args.target);
@@ -809,10 +857,10 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
809
857
 
810
858
  let channelId: string;
811
859
  if (args.channelId) channelId = args.channelId;
812
- else channelId = await resolveChannel(token, ref);
860
+ else channelId = await resolveChannel(token, ref, args.cookie);
813
861
 
814
862
  // Fetch the message to display the original text and compute the safety hash.
815
- const resp = (await replies(token, channelId, ts, 1)) as Record<string, Json>;
863
+ const resp = (await replies(token, channelId, ts, 1, args.cookie)) as Record<string, Json>;
816
864
  const msgs = asArray(resp.messages).map(asRecord);
817
865
  const original = msgs.find((m) => String(m.ts) === ts);
818
866
  if (!original) {
@@ -823,7 +871,7 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
823
871
 
824
872
  // Convert @handle → <@USERID> before hashing/editing (unresolved stay as text).
825
873
  const newText = args.mentions
826
- ? await encodeMentions(token, args.newText, channelId)
874
+ ? await encodeMentions(token, args.newText, channelId, args.cookie ? { cookie: args.cookie } : {})
827
875
  : args.newText;
828
876
 
829
877
  const code = safetyCode(originalText, newText);
@@ -837,7 +885,7 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
837
885
  ]);
838
886
  }
839
887
 
840
- const newTs = await editMessage(token, channelId, ts, newText);
888
+ const newTs = await editMessage(token, channelId, ts, newText, args.cookie);
841
889
  console.log(`✓ Edited (ts: ${newTs})`);
842
890
  }
843
891
 
@@ -846,6 +894,7 @@ interface DeleteArgs {
846
894
  target: string;
847
895
  code?: string;
848
896
  channelId?: string;
897
+ cookie?: string;
849
898
  }
850
899
  async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
851
900
  const { ref, ts } = splitRefTs(args.target);
@@ -856,10 +905,10 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
856
905
 
857
906
  let channelId: string;
858
907
  if (args.channelId) channelId = args.channelId;
859
- else channelId = await resolveChannel(token, ref);
908
+ else channelId = await resolveChannel(token, ref, args.cookie);
860
909
 
861
910
  // Fetch the message to display what is being deleted and compute the safety hash.
862
- const resp = (await replies(token, channelId, ts, 1)) as Record<string, Json>;
911
+ const resp = (await replies(token, channelId, ts, 1, args.cookie)) as Record<string, Json>;
863
912
  const msgs = asArray(resp.messages).map(asRecord);
864
913
  const original = msgs.find((m) => String(m.ts) === ts);
865
914
  if (!original) {
@@ -870,7 +919,7 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
870
919
 
871
920
  const code = safetyCode(channelId, ts, originalText);
872
921
  if (args.code !== code) {
873
- const dest = await destLabel(token, channelId, ref);
922
+ const dest = await destLabel(token, channelId, ref, args.cookie);
874
923
  requireCode(args.code, code, [
875
924
  `--- Deleting message -------------------------`,
876
925
  ` → ${dest} at ${slackTsToIso(ts)}`,
@@ -879,7 +928,7 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
879
928
  ]);
880
929
  }
881
930
 
882
- await deleteMessage(token, channelId, ts);
931
+ await deleteMessage(token, channelId, ts, args.cookie);
883
932
  console.log(`✓ Deleted (ts: ${ts})`);
884
933
  }
885
934
 
@@ -889,6 +938,7 @@ interface ReactArgs {
889
938
  emoji: string;
890
939
  remove?: boolean;
891
940
  channelId?: string;
941
+ cookie?: string;
892
942
  }
893
943
  // Add (or --remove) an emoji reaction. No confirm gate: a reaction is trivial
894
944
  // and fully reversible (`--remove`), and the whole point is a lightweight ack
@@ -904,13 +954,13 @@ async function cmdReact(token: string, args: ReactArgs): Promise<void> {
904
954
 
905
955
  let channelId: string;
906
956
  if (args.channelId) channelId = args.channelId;
907
- else channelId = await resolveChannel(token, ref);
957
+ else channelId = await resolveChannel(token, ref, args.cookie);
908
958
 
909
959
  if (args.remove) {
910
- await reactionRemove(token, channelId, ts, emoji);
960
+ await reactionRemove(token, channelId, ts, emoji, args.cookie);
911
961
  console.log(`✓ Removed :${emoji}: (ts: ${ts})`);
912
962
  } else {
913
- await reactionAdd(token, channelId, ts, emoji);
963
+ await reactionAdd(token, channelId, ts, emoji, args.cookie);
914
964
  console.log(`✓ Reacted :${emoji}: (ts: ${ts})`);
915
965
  }
916
966
  }
@@ -932,18 +982,22 @@ interface SendArgs {
932
982
  // Session cookie (xoxd) for the mention token — required for an xoxc- user
933
983
  // token to call users.list on the public API (it is rejected without it).
934
984
  mentionCookie?: string;
985
+ // Session cookie (xoxd) for `token` itself — required for an xoxc- desktop
986
+ // session token to be accepted by the public Slack API at all (chat.postMessage
987
+ // included). Not set when sending --as-bot (the bot token needs no cookie).
988
+ cookie?: string;
935
989
  }
936
990
 
937
991
  // Detect the silent-failure footgun: DMing yourself with your own user token.
938
992
  // Slack does not notify you about messages you sent to yourself, so an
939
993
  // escalation DM via `send @me` (or @your-own-handle) is delivered but never
940
994
  // surfaces. Returns true when ref names the token's own user.
941
- async function isSelfDm(token: string, ref: string): Promise<boolean> {
995
+ async function isSelfDm(token: string, ref: string, cookie?: string): Promise<boolean> {
942
996
  if (!ref.startsWith("@")) return false;
943
997
  const name = ref.slice(1).toLowerCase().replace(/[^a-z0-9]/g, "");
944
998
  if (name === "me" || name === "you") return true;
945
999
  try {
946
- const self = await authTest(token);
1000
+ const self = await authTest(token, cookie);
947
1001
  const selfName = (self.user ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
948
1002
  return selfName !== "" && selfName === name;
949
1003
  } catch {
@@ -953,10 +1007,11 @@ async function isSelfDm(token: string, ref: string): Promise<boolean> {
953
1007
 
954
1008
  async function cmdSend(token: string, args: SendArgs): Promise<void> {
955
1009
  const { ref, threadTs } = parseTargetThread(args.target);
1010
+ const cookie = args.cookie;
956
1011
 
957
1012
  // Guard the self-DM footgun before doing anything else, unless sending as the
958
1013
  // bot (which delivers a notifiable DM from a different identity).
959
- if (!args.asBot && !args.channelId && !args.userId && await isSelfDm(token, ref)) {
1014
+ if (!args.asBot && !args.channelId && !args.userId && await isSelfDm(token, ref, cookie)) {
960
1015
  console.error(
961
1016
  `Warning: "${ref}" is a DM to yourself — Slack will NOT notify you of your own message.\n` +
962
1017
  ` To reach yourself with a notification, send as the bot: slack send '${ref}' '...' --as-bot`,
@@ -965,8 +1020,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
965
1020
 
966
1021
  let channelId: string;
967
1022
  if (args.channelId) channelId = args.channelId;
968
- else if (args.userId) channelId = await openDm(token, args.userId);
969
- else channelId = await resolveChannel(token, ref);
1023
+ else if (args.userId) channelId = await openDm(token, args.userId, cookie);
1024
+ else channelId = await resolveChannel(token, ref, cookie);
970
1025
 
971
1026
  // Convert @handle / @名前 tokens to <@USERID> before hashing/sending so the
972
1027
  // safety gate covers exactly what will be posted. Unresolved tokens stay as
@@ -1021,38 +1076,99 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1021
1076
  let lastText = "";
1022
1077
  let lastUser = "?";
1023
1078
  let threadMsgs: Record<string, Json>[] = [];
1079
+ // Author of the destination's TRUE most recent message (thread-scoped for a
1080
+ // thread reply, channel-scoped for a top-level send) — feeds the unreplied-warn
1081
+ // check below. Tracks `user` (normal message) and `bot_id` (a `bot_message`
1082
+ // subtype post — e.g. `send --as-bot` — which carries bot_id/username but no
1083
+ // `user`) so ownership can be determined either way. Undefined when the
1084
+ // preview fetch failed (fail-soft).
1085
+ let lastMsgUserId: string | undefined;
1086
+ let lastMsgBotId: string | undefined;
1087
+ let lastMsgTs: string | undefined;
1024
1088
  if (threadTs) {
1025
1089
  try {
1026
1090
  // Fetch a generous window and preview its tail. conversations.replies is
1027
1091
  // oldest-first from the parent, so a very long thread's true tail may lie
1028
1092
  // beyond this window — 100 covers essentially all real threads.
1029
- const resp = (await replies(token, channelId, threadTs, 100)) as Record<string, Json>;
1093
+ const resp = (await replies(token, channelId, threadTs, 100, cookie)) as Record<string, Json>;
1030
1094
  threadMsgs = asArray(resp.messages).map(asRecord);
1031
1095
  const lastMsg = threadMsgs[threadMsgs.length - 1];
1032
1096
  // Bind the hash to the thread's most recent message: if someone replies
1033
1097
  // between preview and confirm, the code invalidates and re-previews.
1034
1098
  lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
1099
+ lastMsgUserId = typeof lastMsg?.user === "string" ? lastMsg.user : undefined;
1100
+ lastMsgBotId = typeof lastMsg?.bot_id === "string" ? lastMsg.bot_id : undefined;
1101
+ lastMsgTs = typeof lastMsg?.ts === "string" ? lastMsg.ts : undefined;
1035
1102
  } catch {
1036
1103
  // best-effort preview only
1037
1104
  }
1038
1105
  } else {
1039
1106
  try {
1040
- const ctx = (await history(token, channelId, 1)) as Record<string, Json>;
1041
- const lastMsg = asArray(ctx.messages).map(asRecord)
1042
- .filter((m) => m.subtype === undefined || m.subtype === null)[0];
1107
+ const ctx = (await history(token, channelId, 1, undefined, undefined, cookie)) as Record<string, Json>;
1108
+ // limit=1 already narrows this to exactly the channel's true last message
1109
+ // (subtype or not) take it raw for the ownership check below. The
1110
+ // subtype filter is only for the human-readable preview text/author: it
1111
+ // deliberately hides system events (channel_join etc.), but a bot_message
1112
+ // post is a real message and must NOT be dropped here, or a bot's own
1113
+ // last post would silently disable the unreplied-warn for --as-bot sends.
1114
+ const rawMsgs = asArray(ctx.messages).map(asRecord);
1115
+ const lastMsg = rawMsgs.filter((m) => m.subtype === undefined || m.subtype === null)[0];
1043
1116
  lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
1044
1117
  // Resolve the author's user ID to a display name for the preview; fall back
1045
1118
  // to the bot username, then the raw ID. userName() is fail-soft.
1046
1119
  lastUser = typeof lastMsg?.user === "string"
1047
- ? await userName(token, lastMsg.user)
1120
+ ? await userName(token, lastMsg.user, cookie)
1048
1121
  : typeof lastMsg?.username === "string"
1049
1122
  ? lastMsg.username
1050
1123
  : "?";
1124
+ const rawLast = rawMsgs[0];
1125
+ lastMsgUserId = typeof rawLast?.user === "string" ? rawLast.user : undefined;
1126
+ lastMsgBotId = typeof rawLast?.bot_id === "string" ? rawLast.bot_id : undefined;
1127
+ lastMsgTs = typeof rawLast?.ts === "string" ? rawLast.ts : undefined;
1051
1128
  } catch {
1052
1129
  // best-effort preview only
1053
1130
  }
1054
1131
  }
1055
1132
 
1133
+ // Unreplied guard: the destination's last message is our own and no one has
1134
+ // replied since (by definition, since it's still the most recent message) —
1135
+ // sending another new message risks spamming the same person twice. Warn
1136
+ // (never block) and point at `slack edit` on that message instead. Reply-status
1137
+ // based, not time-based, so it still fires even long after the last send.
1138
+ // Self-identity covers both a normal post (`user` = our user id) and a
1139
+ // `bot_message`-subtype post (`bot_id` = our app's bot id, no `user`), the
1140
+ // shape `send --as-bot` produces. Opt out with SLACK_UNREPLIED_WARN=0.
1141
+ if (process.env.SLACK_UNREPLIED_WARN !== "0" && lastMsgTs && (lastMsgUserId || lastMsgBotId)) {
1142
+ try {
1143
+ const self = await authScopes(token, cookie);
1144
+ const isSelfAuthor =
1145
+ (!!lastMsgUserId && !!self.userId && self.userId === lastMsgUserId) ||
1146
+ (!!lastMsgBotId && !!self.botId && self.botId === lastMsgBotId);
1147
+ if (isSelfAuthor) {
1148
+ // Prefer a real permalink (exact, copy-pasteable). If that lookup fails,
1149
+ // fall back to a form guaranteed parseable by `slack edit` regardless of
1150
+ // what shape `ref` is in (a bare channel/user name, a raw ID resolved
1151
+ // from a permalink, or a thread ref) — `#<channelId>:<ts>` always yields
1152
+ // a ts split, and `--channel-id` makes the leading "#name" irrelevant to
1153
+ // resolution, so it never depends on ref's original prefix or an
1154
+ // embedded thread_ts colliding with the appended message ts.
1155
+ let editCmd = `slack edit "#${channelId}:${lastMsgTs}" "<新しい本文>" --channel-id ${channelId}`;
1156
+ try {
1157
+ const permalink = await getPermalink(token, channelId, lastMsgTs, cookie);
1158
+ if (permalink) editCmd = `slack edit "${permalink}" "<新しい本文>"`;
1159
+ } catch {
1160
+ // fall back to the --channel-id form above
1161
+ }
1162
+ console.error(`⚠ 相手はまだ返信していません(最後のメッセージはあなたのものです)。`);
1163
+ console.error(` 連投を避けるため、新規送信でなく直前のメッセージの edit を検討してください:`);
1164
+ console.error(` ${editCmd}`);
1165
+ console.error(` このまま送信する場合は --code=XXXX で確定。`);
1166
+ }
1167
+ } catch {
1168
+ // best-effort; a failed self-identity lookup must not block the send
1169
+ }
1170
+ }
1171
+
1056
1172
  // Duplicate guard: if the outgoing message closely matches something already
1057
1173
  // in the thread, warn (never block). Catches re-posting a reply you already
1058
1174
  // sent. Threshold is deliberately high so only near-identical text trips it.
@@ -1066,7 +1182,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1066
1182
  }
1067
1183
  if (best.msg && best.sim >= 0.82) {
1068
1184
  const pct = Math.round(best.sim * 100);
1069
- console.error(`⚠ possible duplicate: ${pct}% similar to an existing thread message — ${await threadMsgLine(token, best.msg)}`);
1185
+ console.error(`⚠ possible duplicate: ${pct}% similar to an existing thread message — ${await threadMsgLine(token, best.msg, 60, cookie)}`);
1070
1186
  }
1071
1187
  }
1072
1188
 
@@ -1075,14 +1191,14 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1075
1191
  const code = safetyCode(channelId, threadTs ?? "", lastText, message);
1076
1192
 
1077
1193
  if (args.code !== code) {
1078
- const dest = await destLabel(token, channelId, ref);
1194
+ const dest = await destLabel(token, channelId, ref, cookie);
1079
1195
  if (threadTs) {
1080
- const parentLine = await threadParentLine(token, threadTs, threadMsgs);
1196
+ const parentLine = await threadParentLine(token, threadTs, threadMsgs, cookie);
1081
1197
  // Preview the tail of the thread (up to 3 most recent messages) so the
1082
1198
  // sender can see what's already been said and avoid repeating it.
1083
1199
  const recent = threadMsgs.slice(-3);
1084
1200
  const recentLines = recent.length
1085
- ? await Promise.all(recent.map(async (m) => ` ${await threadMsgLine(token, m)}`))
1201
+ ? await Promise.all(recent.map(async (m) => ` ${await threadMsgLine(token, m, 60, cookie)}`))
1086
1202
  : [" (thread context unavailable)"];
1087
1203
  requireCode(args.code, code, [
1088
1204
  `--- Recent messages in thread ----------------`,
@@ -1105,10 +1221,10 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1105
1221
  ]);
1106
1222
  }
1107
1223
  }
1108
- const ts = await slackSend(token, channelId, message, threadTs, args.broadcast);
1224
+ const ts = await slackSend(token, channelId, message, threadTs, args.broadcast, cookie);
1109
1225
  let permalink = "";
1110
1226
  try {
1111
- permalink = await getPermalink(token, channelId, ts);
1227
+ permalink = await getPermalink(token, channelId, ts, cookie);
1112
1228
  } catch {
1113
1229
  // fail-soft: getPermalink failure (rate limit, network, etc.) should not
1114
1230
  // mask send success — fall back to ts-only output below.
@@ -1162,14 +1278,15 @@ interface ScheduleSendArgs {
1162
1278
  code?: string;
1163
1279
  channelId?: string;
1164
1280
  userId?: string;
1281
+ cookie?: string;
1165
1282
  }
1166
1283
  async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
1167
1284
  const { ref, threadTs } = parseTargetThread(args.target);
1168
1285
 
1169
1286
  let channelId: string;
1170
1287
  if (args.channelId) channelId = args.channelId;
1171
- else if (args.userId) channelId = await openDm(token, args.userId);
1172
- else channelId = await resolveChannel(token, ref);
1288
+ else if (args.userId) channelId = await openDm(token, args.userId, args.cookie);
1289
+ else channelId = await resolveChannel(token, ref, args.cookie);
1173
1290
 
1174
1291
  const postAt = parsePostAt(args.at);
1175
1292
  const postAtDate = new Date(postAt * 1000).toISOString();
@@ -1184,18 +1301,18 @@ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<v
1184
1301
  `---------------------------------------------`,
1185
1302
  ]);
1186
1303
  }
1187
- const id = await scheduleMessage(token, channelId, args.message, postAt, threadTs);
1304
+ const id = await scheduleMessage(token, channelId, args.message, postAt, threadTs, args.cookie);
1188
1305
  console.log(`✓ Scheduled (id: ${id}, at: ${postAtDate})`);
1189
1306
  }
1190
1307
 
1191
- async function cmdScheduleList(token: string, target?: string, channelId?: string): Promise<void> {
1308
+ async function cmdScheduleList(token: string, target?: string, channelId?: string, cookie?: string): Promise<void> {
1192
1309
  let channel: string | undefined;
1193
1310
  if (channelId) {
1194
1311
  channel = channelId;
1195
1312
  } else if (target) {
1196
- channel = await resolveChannel(token, target);
1313
+ channel = await resolveChannel(token, target, cookie);
1197
1314
  }
1198
- const resp = (await listScheduledMessages(token, channel)) as {
1315
+ const resp = (await listScheduledMessages(token, channel, cookie)) as {
1199
1316
  scheduled_messages?: { id: string; channel_id: string; post_at: number; text: string }[];
1200
1317
  };
1201
1318
  const msgs = resp.scheduled_messages ?? [];
@@ -1211,11 +1328,12 @@ interface ScheduleRmArgs {
1211
1328
  id: string;
1212
1329
  code?: string;
1213
1330
  channelId?: string;
1331
+ cookie?: string;
1214
1332
  }
1215
1333
  async function cmdScheduleRm(token: string, args: ScheduleRmArgs): Promise<void> {
1216
1334
  let channelId: string;
1217
1335
  if (args.channelId) channelId = args.channelId;
1218
- else channelId = await resolveChannel(token, args.target);
1336
+ else channelId = await resolveChannel(token, args.target, args.cookie);
1219
1337
 
1220
1338
  const code = safetyCode(channelId, args.id);
1221
1339
  if (args.code !== code) {
@@ -1226,7 +1344,7 @@ async function cmdScheduleRm(token: string, args: ScheduleRmArgs): Promise<void>
1226
1344
  `---------------------------------------------`,
1227
1345
  ]);
1228
1346
  }
1229
- await deleteScheduledMessage(token, channelId, args.id);
1347
+ await deleteScheduledMessage(token, channelId, args.id, args.cookie);
1230
1348
  console.log(`✓ Deleted scheduled message ${args.id}`);
1231
1349
  }
1232
1350
 
@@ -1239,6 +1357,7 @@ interface UploadArgs {
1239
1357
  code?: string;
1240
1358
  channelId?: string;
1241
1359
  userId?: string;
1360
+ cookie?: string;
1242
1361
  }
1243
1362
  async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
1244
1363
  const { statSync, existsSync } = await import("node:fs");
@@ -1255,8 +1374,8 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
1255
1374
 
1256
1375
  let channelId: string;
1257
1376
  if (args.channelId) channelId = args.channelId;
1258
- else if (args.userId) channelId = await openDm(token, args.userId);
1259
- else channelId = await resolveChannel(token, ref);
1377
+ else if (args.userId) channelId = await openDm(token, args.userId, args.cookie);
1378
+ else channelId = await resolveChannel(token, ref, args.cookie);
1260
1379
 
1261
1380
  const isBatch = args.filePaths.length > 1;
1262
1381
  const files = args.filePaths.map((fp) => {
@@ -1293,7 +1412,7 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
1293
1412
  const uploadOpts: { title?: string; threadTs?: string; initialComment?: string } = { title: f.title };
1294
1413
  if (threadTs !== undefined) uploadOpts.threadTs = threadTs;
1295
1414
  if (args.comment !== undefined && i === 0) uploadOpts.initialComment = args.comment;
1296
- const { fileId, permalink } = await uploadFile(token, channelId, f.fp, uploadOpts);
1415
+ const { fileId, permalink } = await uploadFile(token, channelId, f.fp, uploadOpts, args.cookie);
1297
1416
  const prefix = total > 1 ? `[${i + 1}/${total}] ` : "";
1298
1417
  console.log(`${prefix}✓ Uploaded (file_id: ${fileId}${permalink ? `, url: ${permalink}` : ""})`);
1299
1418
  }
@@ -1429,10 +1548,11 @@ async function main(): Promise<void> {
1429
1548
  (y2) => y2.positional("channel", { type: "string", demandOption: true, describe: "#name or channel ID" }),
1430
1549
  async (argv) => {
1431
1550
  const token = tok(argv as W);
1551
+ const cookie = ck(argv as W);
1432
1552
  const ref = argv.channel!;
1433
1553
  const channelRef = ref.startsWith("#") || ref.startsWith("@") || ref.startsWith("C") || ref.startsWith("G") || ref.startsWith("D") ? ref : `#${ref}`;
1434
- const channelId = await resolveChannel(token, channelRef);
1435
- const resp = asRecord((await conversationInfo(token, channelId)) as Json);
1554
+ const channelId = await resolveChannel(token, channelRef, cookie);
1555
+ const resp = asRecord((await conversationInfo(token, channelId, cookie)) as Json);
1436
1556
  const ch = asRecord(resp.channel);
1437
1557
  const name = typeof ch.name === "string" ? ch.name : channelId;
1438
1558
  const isIm = ch.is_im === true;
@@ -1457,6 +1577,7 @@ async function main(): Promise<void> {
1457
1577
  .option("code", { type: "string", describe: "Safety hash to confirm create" }),
1458
1578
  async (argv) => {
1459
1579
  const token = tok(argv as W);
1580
+ const cookie = ck(argv as W);
1460
1581
  // Slack lowercases and strips the leading #; normalize for preview + code.
1461
1582
  const name = argv.name!.replace(/^#/, "").toLowerCase();
1462
1583
  const isPrivate = argv.private === true;
@@ -1471,7 +1592,7 @@ async function main(): Promise<void> {
1471
1592
  ]);
1472
1593
  let created: { id: string; name: string };
1473
1594
  try {
1474
- created = await createChannel(token, name, isPrivate);
1595
+ created = await createChannel(token, name, isPrivate, cookie);
1475
1596
  } catch (e: unknown) {
1476
1597
  console.error(e instanceof Error ? e.message : String(e));
1477
1598
  process.exit(1);
@@ -1481,14 +1602,32 @@ async function main(): Promise<void> {
1481
1602
  const ids: string[] = [];
1482
1603
  for (const ref of invites) {
1483
1604
  try {
1484
- ids.push(ref.startsWith("U") || ref.startsWith("W") ? ref : await resolveUserId(token, ref));
1605
+ ids.push(ref.startsWith("U") || ref.startsWith("W") ? ref : await resolveUserId(token, ref, cookie));
1485
1606
  } catch (e: unknown) {
1486
1607
  console.error(` ! could not resolve ${ref}: ${e instanceof Error ? e.message : String(e)}`);
1487
1608
  }
1488
1609
  }
1610
+ // conversations.invite returns ok:true but silently no-ops for a
1611
+ // single-channel guest (is_ultra_restricted) — they can only ever
1612
+ // belong to the one channel they were created in. Warn up front
1613
+ // (fail-soft, never blocks) so a no-op invite isn't mistaken for
1614
+ // success.
1615
+ for (const uid of ids) {
1616
+ try {
1617
+ const info = asRecord((await userInfo(token, uid, cookie)) as Json);
1618
+ const u = asRecord(info.user);
1619
+ if (u.is_ultra_restricted === true) {
1620
+ console.error(`⚠ ${uid} is a single-channel guest — Slack restricts them to their one existing channel; this invite will likely report success but not actually add them.`);
1621
+ } else if (u.is_restricted === true) {
1622
+ console.error(`⚠ ${uid} is a multi-channel guest — double-check they were actually added (guest invites can silently no-op depending on workspace restrictions).`);
1623
+ }
1624
+ } catch {
1625
+ // best-effort; a lookup failure must not block the invite
1626
+ }
1627
+ }
1489
1628
  if (ids.length) {
1490
1629
  try {
1491
- await inviteToChannel(token, created.id, ids);
1630
+ await inviteToChannel(token, created.id, ids, cookie);
1492
1631
  console.log(`✓ Invited ${ids.length} member${ids.length === 1 ? "" : "s"}`);
1493
1632
  } catch (e: unknown) {
1494
1633
  console.error(` ! invite failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -1619,7 +1758,7 @@ async function main(): Promise<void> {
1619
1758
  .option("count", { alias: "n", type: "number", default: 100 })
1620
1759
  .option("json", { type: "boolean", default: false, describe: "Output raw JSON" }),
1621
1760
  async (argv) => {
1622
- await cmdSearch(tok(argv as W), argv.query!, argv.count, argv.json);
1761
+ await cmdSearch(tok(argv as W), argv.query!, argv.count, argv.json, ck(argv as W));
1623
1762
  },
1624
1763
  )
1625
1764
  .command(
@@ -1668,7 +1807,7 @@ async function main(): Promise<void> {
1668
1807
  // (not the user's own self-DM).
1669
1808
  if (!args.channelId && !args.userId && args.target.startsWith("@")) {
1670
1809
  try {
1671
- args.userId = await resolveUserId(tok(argv as W), args.target);
1810
+ args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
1672
1811
  } catch (e: unknown) {
1673
1812
  console.error(
1674
1813
  `Error: could not resolve ${args.target} to a user for the bot DM.\n` +
@@ -1682,6 +1821,11 @@ async function main(): Promise<void> {
1682
1821
  args.asBot = true;
1683
1822
  } else {
1684
1823
  sendToken = tok(argv as W);
1824
+ // Session cookie for `sendToken` itself — needed for an xoxc- desktop
1825
+ // token to be accepted by the public API at all. Not applicable to the
1826
+ // bot token above (bot tokens need no cookie).
1827
+ const sc = ck(argv as W);
1828
+ if (sc) args.cookie = sc;
1685
1829
  }
1686
1830
  // Print a clean error instead of yargs' usage dump when a send fails at
1687
1831
  // runtime (e.g. missing scope, channel not found). The confirm gate
@@ -1729,6 +1873,8 @@ async function main(): Promise<void> {
1729
1873
  if (argv.code) args.code = argv.code;
1730
1874
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1731
1875
  if (argv["user-id"]) args.userId = argv["user-id"];
1876
+ const cookie = ck(argv as W);
1877
+ if (cookie) args.cookie = cookie;
1732
1878
  await cmdScheduleSend(tok(argv as W), args);
1733
1879
  },
1734
1880
  )
@@ -1739,7 +1885,7 @@ async function main(): Promise<void> {
1739
1885
  .positional("target", { type: "string", describe: "#channel to filter by" })
1740
1886
  .option("channel-id", { type: "string", describe: "Raw channel ID" }),
1741
1887
  async (argv) => {
1742
- await cmdScheduleList(tok(argv as W), argv.target as string | undefined, argv["channel-id"]);
1888
+ await cmdScheduleList(tok(argv as W), argv.target as string | undefined, argv["channel-id"], ck(argv as W));
1743
1889
  },
1744
1890
  )
1745
1891
  .command(
@@ -1753,6 +1899,8 @@ async function main(): Promise<void> {
1753
1899
  async (argv) => {
1754
1900
  const args: ScheduleRmArgs = { target: argv.target!, id: argv.id! };
1755
1901
  if (argv.code) args.code = argv.code;
1902
+ const cookie = ck(argv as W);
1903
+ if (cookie) args.cookie = cookie;
1756
1904
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1757
1905
  await cmdScheduleRm(tok(argv as W), args);
1758
1906
  },
@@ -1775,6 +1923,8 @@ async function main(): Promise<void> {
1775
1923
  if (argv.code) args.code = argv.code;
1776
1924
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1777
1925
  if (argv.mentions !== false) args.mentions = true;
1926
+ const cookie = ck(argv as W);
1927
+ if (cookie) args.cookie = cookie;
1778
1928
  await cmdEdit(tok(argv as W), args);
1779
1929
  },
1780
1930
  )
@@ -1789,6 +1939,8 @@ async function main(): Promise<void> {
1789
1939
  const args: DeleteArgs = { target: argv.target! };
1790
1940
  if (argv.code) args.code = argv.code;
1791
1941
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1942
+ const cookie = ck(argv as W);
1943
+ if (cookie) args.cookie = cookie;
1792
1944
  await cmdDelete(tok(argv as W), args);
1793
1945
  },
1794
1946
  )
@@ -1804,6 +1956,8 @@ async function main(): Promise<void> {
1804
1956
  const args: ReactArgs = { target: argv.target!, emoji: argv.emoji! };
1805
1957
  if (argv.remove) args.remove = true;
1806
1958
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1959
+ const cookie = ck(argv as W);
1960
+ if (cookie) args.cookie = cookie;
1807
1961
  // Print a clean one-line error instead of yargs' usage dump + stack when
1808
1962
  // the reaction fails at runtime (missing_scope, invalid_name, etc.).
1809
1963
  try {
@@ -1832,6 +1986,8 @@ async function main(): Promise<void> {
1832
1986
  if (argv.code) args.code = argv.code;
1833
1987
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1834
1988
  if (argv["user-id"]) args.userId = argv["user-id"];
1989
+ const cookie = ck(argv as W);
1990
+ if (cookie) args.cookie = cookie;
1835
1991
  await cmdUpload(tok(argv as W), args);
1836
1992
  },
1837
1993
  )