slack-term 1.20.0 → 1.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/cli.ts CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  updateDraft,
25
25
  editMessage,
26
26
  deleteMessage,
27
+ reactionAdd,
28
+ reactionRemove,
27
29
  filesInfo,
28
30
  history,
29
31
  listConversations,
@@ -588,6 +590,49 @@ function safetyCode(...parts: string[]): string {
588
590
  return sha256Hex(parts.join("\n")).slice(0, 4);
589
591
  }
590
592
 
593
+ // Slack API method → the token scope that grants it, for missing_scope guidance.
594
+ const SCOPE_FOR_METHOD: Record<string, string> = {
595
+ "reactions.add": "reactions:write",
596
+ "reactions.remove": "reactions:write",
597
+ "chat.postMessage": "chat:write",
598
+ "chat.update": "chat:write",
599
+ "chat.delete": "chat:write",
600
+ "conversations.replies": "channels:history (or groups:history)",
601
+ "conversations.history": "channels:history (or groups:history)",
602
+ };
603
+
604
+ /** Turn a raw Slack API error into a single clean line of guidance, hiding the
605
+ * yargs usage dump / stack trace that a thrown handler would otherwise print.
606
+ * Recognizes the common reaction/message failure codes; falls back to the
607
+ * original message for anything unmapped. */
608
+ function friendlySlackError(e: unknown): string {
609
+ const raw = e instanceof Error ? e.message : String(e);
610
+ const m = raw.match(/Slack error on (\S+): (\w+)/);
611
+ if (!m) return raw;
612
+ const [, method, code] = m;
613
+ switch (code) {
614
+ case "missing_scope": {
615
+ const scope = SCOPE_FOR_METHOD[method!] ?? "the required";
616
+ // Note the token-type gotcha: the CLI uses the USER token (xoxp) by default,
617
+ // so the scope must be in "User Token Scopes" — adding it only to "Bot Token
618
+ // Scopes" fixes `--as-bot`/`doctor` but not the default path. Then reinstall.
619
+ return `Error: token lacks the ${scope} scope (needed for ${method}). Add it to your Slack App's User Token Scopes (or Bot Token Scopes if you use a bot token / --as-bot) — it must be on the token type the CLI actually uses — then reinstall to the workspace.`;
620
+ }
621
+ case "invalid_name":
622
+ return `Error: not a valid emoji shortcode — use the name without colons (e.g. white_check_mark), and make sure it exists in the workspace.`;
623
+ case "already_reacted":
624
+ return `Error: you've already added that reaction to the message.`;
625
+ case "no_reaction":
626
+ return `Error: that reaction isn't on the message — nothing to remove.`;
627
+ case "message_not_found":
628
+ return `Error: no message found at that timestamp in the channel — check the target ts/permalink.`;
629
+ case "channel_not_found":
630
+ return `Error: channel not found — check the target.`;
631
+ default:
632
+ return raw;
633
+ }
634
+ }
635
+
591
636
  /** Dry-run gate: print context, print required --code=, exit 1.
592
637
  * Call this when --code is absent or wrong. */
593
638
  function requireCode(provided: string | undefined, expected: string, contextLines: string[]): void {
@@ -661,26 +706,73 @@ async function destLabel(token: string, channelId: string, ref: string): Promise
661
706
  return name === channelId ? channelId : `${name} (${channelId})`;
662
707
  }
663
708
 
709
+ /** One-line description of a single thread message for confirm gates:
710
+ * "2026-06-11 08:05 @handle: head…". `headLen` bounds the text preview. */
711
+ async function threadMsgLine(token: string, m: Record<string, Json>, headLen = 60): Promise<string> {
712
+ const author = typeof m.user === "string"
713
+ ? await userName(token, m.user)
714
+ : typeof m.username === "string"
715
+ ? m.username
716
+ : "?";
717
+ const head = (typeof m.text === "string" ? m.text : "").split("\n")[0] ?? "";
718
+ const headShort = head.length > headLen ? `${head.slice(0, headLen)}…` : head;
719
+ const ts = typeof m.ts === "string" ? m.ts : "";
720
+ const stamp = ts ? slackTsToIso(ts).slice(0, 16).replace("T", " ") : "";
721
+ return `${stamp} @${author}${headShort ? `: ${headShort}` : ""}`;
722
+ }
723
+
664
724
  /** One-line thread-parent description for confirm gates: "2026-06-11 08:05 @handle: head…".
665
- * Fail-soft: returns the raw ts if the parent cannot be fetched. */
666
- async function threadParentLine(token: string, channelId: string, threadTs: string): Promise<string> {
667
- try {
668
- const resp = (await replies(token, channelId, threadTs, 1)) as Record<string, Json>;
669
- const msgs = asArray(resp.messages).map(asRecord);
670
- const parent = msgs.find((m) => String(m.ts) === threadTs) ?? msgs[0];
671
- if (!parent) return threadTs;
672
- const author = typeof parent.user === "string"
673
- ? await userName(token, parent.user)
674
- : typeof parent.username === "string"
675
- ? parent.username
676
- : "?";
677
- const head = (typeof parent.text === "string" ? parent.text : "").split("\n")[0] ?? "";
678
- const headShort = head.length > 40 ? `${head.slice(0, 40)}…` : head;
679
- const stamp = slackTsToIso(threadTs).slice(0, 16).replace("T", " ");
680
- return `${stamp} @${author}${headShort ? `: ${headShort}` : ""}`;
681
- } catch {
682
- return threadTs;
725
+ * Fail-soft: returns the raw ts if the parent cannot be found. */
726
+ async function threadParentLine(token: string, threadTs: string, threadMsgs: Record<string, Json>[]): Promise<string> {
727
+ const parent = threadMsgs.find((m) => String(m.ts) === threadTs) ?? threadMsgs[0];
728
+ if (!parent) return threadTs;
729
+ return threadMsgLine(token, parent, 40);
730
+ }
731
+
732
+ /** Normalize a message for duplicate comparison: lowercase, collapse whitespace,
733
+ * strip <@U…>/<#C…> markup and surrounding punctuation so cosmetic differences
734
+ * (a trailing period, an added mention) don't hide a re-post. */
735
+ function normalizeForDup(s: string): string {
736
+ return s
737
+ .toLowerCase()
738
+ .replace(/<[@#!][^>]+>/g, " ")
739
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
740
+ .replace(/\s+/g, " ")
741
+ .trim();
742
+ }
743
+
744
+ /** Character-bigram Dice coefficient (0..1) over normalized text — robust to
745
+ * minor edits, good at catching near-identical re-posts. Identical strings →1;
746
+ * a shared word or two in otherwise different messages stays low. */
747
+ function textSimilarity(a: string, b: string): number {
748
+ const na = normalizeForDup(a);
749
+ const nb = normalizeForDup(b);
750
+ if (!na || !nb) return 0;
751
+ if (na === nb) return 1;
752
+ const bigrams = (s: string): Map<string, number> => {
753
+ const m = new Map<string, number>();
754
+ for (let i = 0; i < s.length - 1; i++) {
755
+ const g = s.slice(i, i + 2);
756
+ m.set(g, (m.get(g) ?? 0) + 1);
757
+ }
758
+ return m;
759
+ };
760
+ const ba = bigrams(na);
761
+ const bb = bigrams(nb);
762
+ if (ba.size === 0 || bb.size === 0) return 0;
763
+ let overlap = 0;
764
+ for (const [g, count] of ba) {
765
+ const other = bb.get(g);
766
+ if (other) overlap += Math.min(count, other);
683
767
  }
768
+ const total = sumCounts(ba) + sumCounts(bb);
769
+ return total === 0 ? 0 : (2 * overlap) / total;
770
+ }
771
+
772
+ function sumCounts(m: Map<string, number>): number {
773
+ let n = 0;
774
+ for (const v of m.values()) n += v;
775
+ return n;
684
776
  }
685
777
 
686
778
  // --- edit ---
@@ -774,6 +866,38 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
774
866
  console.log(`✓ Deleted (ts: ${ts})`);
775
867
  }
776
868
 
869
+ // --- react ---
870
+ interface ReactArgs {
871
+ target: string;
872
+ emoji: string;
873
+ remove?: boolean;
874
+ channelId?: string;
875
+ }
876
+ // Add (or --remove) an emoji reaction. No confirm gate: a reaction is trivial
877
+ // and fully reversible (`--remove`), and the whole point is a lightweight ack
878
+ // that doesn't grow the thread. The emoji is a shortcode without colons
879
+ // (e.g. "eyes", "white_check_mark", "hourglass").
880
+ async function cmdReact(token: string, args: ReactArgs): Promise<void> {
881
+ const { ref, ts } = splitRefTs(args.target);
882
+ if (!ts) {
883
+ console.error("Error: target must embed a message ts (e.g. #chan:2026-05-11T06:01:04.000100 or a Slack permalink URL)");
884
+ process.exit(2);
885
+ }
886
+ const emoji = args.emoji.replace(/^:|:$/g, "");
887
+
888
+ let channelId: string;
889
+ if (args.channelId) channelId = args.channelId;
890
+ else channelId = await resolveChannel(token, ref);
891
+
892
+ if (args.remove) {
893
+ await reactionRemove(token, channelId, ts, emoji);
894
+ console.log(`✓ Removed :${emoji}: (ts: ${ts})`);
895
+ } else {
896
+ await reactionAdd(token, channelId, ts, emoji);
897
+ console.log(`✓ Reacted :${emoji}: (ts: ${ts})`);
898
+ }
899
+ }
900
+
777
901
  // --- send ---
778
902
  interface SendArgs {
779
903
  target: string;
@@ -841,25 +965,62 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
841
965
  // best-effort; ignore
842
966
  }
843
967
 
844
- // Fetch last 1 message for context hash. Fail-soft: a bot token without
845
- // im:history (or channels:history) can still send only the preview of the
846
- // prior message is lost, so default to empty rather than blocking the send.
968
+ // Gather context for the confirm gate. For a THREAD reply, pull the thread's
969
+ // own recent messages so the preview shows what's already been said there —
970
+ // this is what stops accidental duplicate replies (the channel's last message
971
+ // is irrelevant when you're deep in a thread). For a top-level send, fall back
972
+ // to the channel's last message. Fail-soft throughout: a bot token without
973
+ // history scope can still send; only the preview degrades.
847
974
  let lastText = "";
848
975
  let lastUser = "?";
849
- try {
850
- const ctx = (await history(token, channelId, 1)) as Record<string, Json>;
851
- const lastMsg = asArray(ctx.messages).map(asRecord)
852
- .filter((m) => m.subtype === undefined || m.subtype === null)[0];
853
- lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
854
- // Resolve the author's user ID to a display name for the preview; fall back
855
- // to the bot username, then the raw ID. userName() is fail-soft.
856
- lastUser = typeof lastMsg?.user === "string"
857
- ? await userName(token, lastMsg.user)
858
- : typeof lastMsg?.username === "string"
859
- ? lastMsg.username
860
- : "?";
861
- } catch {
862
- // best-effort preview only
976
+ let threadMsgs: Record<string, Json>[] = [];
977
+ if (threadTs) {
978
+ try {
979
+ // Fetch a generous window and preview its tail. conversations.replies is
980
+ // oldest-first from the parent, so a very long thread's true tail may lie
981
+ // beyond this window 100 covers essentially all real threads.
982
+ const resp = (await replies(token, channelId, threadTs, 100)) as Record<string, Json>;
983
+ threadMsgs = asArray(resp.messages).map(asRecord);
984
+ const lastMsg = threadMsgs[threadMsgs.length - 1];
985
+ // Bind the hash to the thread's most recent message: if someone replies
986
+ // between preview and confirm, the code invalidates and re-previews.
987
+ lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
988
+ } catch {
989
+ // best-effort preview only
990
+ }
991
+ } else {
992
+ try {
993
+ const ctx = (await history(token, channelId, 1)) as Record<string, Json>;
994
+ const lastMsg = asArray(ctx.messages).map(asRecord)
995
+ .filter((m) => m.subtype === undefined || m.subtype === null)[0];
996
+ lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
997
+ // Resolve the author's user ID to a display name for the preview; fall back
998
+ // to the bot username, then the raw ID. userName() is fail-soft.
999
+ lastUser = typeof lastMsg?.user === "string"
1000
+ ? await userName(token, lastMsg.user)
1001
+ : typeof lastMsg?.username === "string"
1002
+ ? lastMsg.username
1003
+ : "?";
1004
+ } catch {
1005
+ // best-effort preview only
1006
+ }
1007
+ }
1008
+
1009
+ // Duplicate guard: if the outgoing message closely matches something already
1010
+ // in the thread, warn (never block). Catches re-posting a reply you already
1011
+ // sent. Threshold is deliberately high so only near-identical text trips it.
1012
+ if (threadTs && threadMsgs.length) {
1013
+ let best = { sim: 0, msg: null as Record<string, Json> | null };
1014
+ for (const m of threadMsgs) {
1015
+ const t = typeof m.text === "string" ? m.text : "";
1016
+ if (!t) continue;
1017
+ const sim = textSimilarity(message, t);
1018
+ if (sim > best.sim) best = { sim, msg: m };
1019
+ }
1020
+ if (best.msg && best.sim >= 0.82) {
1021
+ const pct = Math.round(best.sim * 100);
1022
+ console.error(`⚠ possible duplicate: ${pct}% similar to an existing thread message — ${await threadMsgLine(token, best.msg)}`);
1023
+ }
863
1024
  }
864
1025
 
865
1026
  // Hash covers the destination too — a code minted for one channel/thread
@@ -868,17 +1029,32 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
868
1029
 
869
1030
  if (args.code !== code) {
870
1031
  const dest = await destLabel(token, channelId, ref);
871
- const destLine = threadTs
872
- ? ` → ${dest} thread of ${await threadParentLine(token, channelId, threadTs)} — THREAD REPLY`
873
- : ` → ${dest} NEW top-level message`;
874
- requireCode(args.code, code, [
875
- `--- Last message in channel ------------------`,
876
- ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
877
- `--- Sending ----------------------------------`,
878
- destLine,
879
- ` Message: ${message}`,
880
- `--------------------------------────────────`,
881
- ]);
1032
+ if (threadTs) {
1033
+ const parentLine = await threadParentLine(token, threadTs, threadMsgs);
1034
+ // Preview the tail of the thread (up to 3 most recent messages) so the
1035
+ // sender can see what's already been said and avoid repeating it.
1036
+ const recent = threadMsgs.slice(-3);
1037
+ const recentLines = recent.length
1038
+ ? await Promise.all(recent.map(async (m) => ` ${await threadMsgLine(token, m)}`))
1039
+ : [" (thread context unavailable)"];
1040
+ requireCode(args.code, code, [
1041
+ `--- Recent messages in thread ----------------`,
1042
+ ...recentLines,
1043
+ `--- Sending ----------------------------------`,
1044
+ ` → ${dest} thread of ${parentLine} — THREAD REPLY`,
1045
+ ` Message: ${message}`,
1046
+ `--------------------------------────────────`,
1047
+ ]);
1048
+ } else {
1049
+ requireCode(args.code, code, [
1050
+ `--- Last message in channel ------------------`,
1051
+ ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
1052
+ `--- Sending ----------------------------------`,
1053
+ ` → ${dest} — NEW top-level message`,
1054
+ ` Message: ${message}`,
1055
+ `--------------------------------────────────`,
1056
+ ]);
1057
+ }
882
1058
  }
883
1059
  const ts = await slackSend(token, channelId, message, threadTs, args.broadcast);
884
1060
  let permalink = "";
@@ -1446,7 +1622,7 @@ async function main(): Promise<void> {
1446
1622
  try {
1447
1623
  await cmdSend(sendToken, args);
1448
1624
  } catch (e: unknown) {
1449
- console.error(e instanceof Error ? e.message : String(e));
1625
+ console.error(friendlySlackError(e));
1450
1626
  process.exit(1);
1451
1627
  }
1452
1628
  },
@@ -1549,6 +1725,28 @@ async function main(): Promise<void> {
1549
1725
  await cmdDelete(tok(argv as W), args);
1550
1726
  },
1551
1727
  )
1728
+ .command(
1729
+ "react <target> <emoji>",
1730
+ "Add (or --remove) an emoji reaction — a lightweight ack that doesn't grow the thread",
1731
+ (y) => y
1732
+ .positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
1733
+ .positional("emoji", { type: "string", demandOption: true, describe: "Emoji shortcode without colons (e.g. eyes, white_check_mark, hourglass)" })
1734
+ .option("remove", { type: "boolean", default: false, describe: "Remove the reaction instead of adding it" })
1735
+ .option("channel-id", { type: "string", describe: "Raw channel ID" }),
1736
+ async (argv) => {
1737
+ const args: ReactArgs = { target: argv.target!, emoji: argv.emoji! };
1738
+ if (argv.remove) args.remove = true;
1739
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
1740
+ // Print a clean one-line error instead of yargs' usage dump + stack when
1741
+ // the reaction fails at runtime (missing_scope, invalid_name, etc.).
1742
+ try {
1743
+ await cmdReact(tok(argv as W), args);
1744
+ } catch (e: unknown) {
1745
+ console.error(friendlySlackError(e));
1746
+ process.exit(1);
1747
+ }
1748
+ },
1749
+ )
1552
1750
  .command(
1553
1751
  "upload <target> <file..>",
1554
1752
  "Upload one or more files to a channel or DM",
package/ts/slack.ts CHANGED
@@ -321,6 +321,28 @@ export async function deleteMessage(
321
321
  await post(token, "chat.delete", { channel, ts });
322
322
  }
323
323
 
324
+ // Add or remove an emoji reaction on a message. `name` is the emoji shortcode
325
+ // without colons (e.g. "eyes", "white_check_mark"). Slack's reactions.add
326
+ // returns already_reacted when the reaction exists; reactions.remove returns
327
+ // no_reaction when it doesn't — both surface to the caller as API errors.
328
+ export async function reactionAdd(
329
+ token: string,
330
+ channel: string,
331
+ ts: string,
332
+ name: string,
333
+ ): Promise<void> {
334
+ await post(token, "reactions.add", { channel, timestamp: ts, name });
335
+ }
336
+
337
+ export async function reactionRemove(
338
+ token: string,
339
+ channel: string,
340
+ ts: string,
341
+ name: string,
342
+ ): Promise<void> {
343
+ await post(token, "reactions.remove", { channel, timestamp: ts, name });
344
+ }
345
+
324
346
  // Create a public (or private) channel via conversations.create. Slack
325
347
  // lowercases the name and rejects spaces/most punctuation; the raw API error
326
348
  // (e.g. name_taken, invalid_name_specials) surfaces to the caller. Returns the