slack-term 1.51.0 → 1.51.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.51.0",
3
+ "version": "1.51.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/botIntent.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  /** Write commands, as `argv._` spells them (aliases included). Read-only
12
12
  * commands are never refused — reading as the user impersonates no one. */
13
13
  const WRITES = new Set([
14
- "send", "reply", "outbox flush", "inbox nudge", "ask", "poll", "vote", "edit", "tick", "delete", "react", "upload",
14
+ "send", "reply", "outbox flush", "channel invite", "channels invite", "ch invite", "inbox nudge", "ask", "poll", "vote", "edit", "tick", "delete", "react", "upload",
15
15
  "schedule send", "schedule rm",
16
16
  "pinlog new", "pinlog set", "pinlog note", "pinlog pin",
17
17
  "todo set", "todo flag",
package/ts/cli.ts CHANGED
@@ -2386,6 +2386,8 @@ async function cmdReact(token: string, args: ReactArgs): Promise<void> {
2386
2386
  // --- send ---
2387
2387
  interface SendArgs {
2388
2388
  allowUrlAdjacent?: boolean;
2389
+ /** Print the result as one JSON line: {ok, channel, ts, thread_ts?, permalink?}. */
2390
+ json?: boolean;
2389
2391
  target: string;
2390
2392
  message: string;
2391
2393
  code?: string;
@@ -2724,7 +2726,10 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
2724
2726
  // mask send success — fall back to ts-only output below.
2725
2727
  }
2726
2728
  attr.record({ team: (await getSelf())?.team, channel: channelId, target: args.target, ts, threadTs, permalink, text: message, asBot: args.asBot });
2727
- if (permalink) {
2729
+ if (args.json) {
2730
+ // For scripts: a lane's first post becomes its thread root by this ts.
2731
+ console.log(JSON.stringify({ ok: true, channel: channelId, ts, ...(threadTs ? { thread_ts: threadTs } : {}), ...(permalink ? { permalink } : {}) }));
2732
+ } else if (permalink) {
2728
2733
  console.log(`✓ Sent: ${permalink}`);
2729
2734
  } else {
2730
2735
  console.log(`✓ Sent (ts: ${ts})`);
@@ -2850,6 +2855,17 @@ interface AskArgs {
2850
2855
 
2851
2856
  const askSleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
2852
2857
 
2858
+ /** `-` as the text argument means stdin. yargs hands a lone `-` positional
2859
+ * over as "" — so "" counts too when the raw arguments carry a `-`. */
2860
+ function isStdinArg(v: string | undefined): boolean {
2861
+ return v === "-" || (v === "" && process.argv.slice(2).includes("-"));
2862
+ }
2863
+
2864
+ /** The text piped in, without the trailing newline(s) every file and echo adds. */
2865
+ async function stdinText(): Promise<string> {
2866
+ return (await Bun.stdin.text()).replace(/\s+$/, "");
2867
+ }
2868
+
2853
2869
  // A thread reply sent with "also send to channel" ticked arrives as a normal
2854
2870
  // message carrying this subtype. Dropping every subtype would discard those —
2855
2871
  // and where thread replies are the only text path, that means discarding real
@@ -5681,6 +5697,7 @@ async function main(): Promise<void> {
5681
5697
  workspace?: string | undefined; target?: string | undefined; message?: string | undefined; code?: string | undefined;
5682
5698
  "allow-url-adjacent"?: boolean | undefined; "channel-id"?: string | undefined; "user-id"?: string | undefined;
5683
5699
  "as-bot"?: boolean | undefined; broadcast?: boolean | undefined; mentions?: boolean | undefined;
5700
+ json?: boolean | undefined;
5684
5701
  };
5685
5702
  /** The `edit` handler, shared with `tick`. In a .slack-term/ scope the
5686
5703
  * lane's tag is kept; in a restricted one the message must sit in one of
@@ -5746,11 +5763,13 @@ async function main(): Promise<void> {
5746
5763
  // staged a confirm code and posting it produced a blank message.
5747
5764
  // Verification cannot catch this: an empty publish reads back as an
5748
5765
  // exact match, so refusing here is the only mechanism.
5766
+ if (isStdinArg(argv.message)) argv = { ...argv, message: await stdinText() };
5749
5767
  if (!String(argv.message ?? "").trim()) {
5750
5768
  console.error("Error: message is empty");
5751
5769
  process.exit(1);
5752
5770
  }
5753
5771
  const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] === true };
5772
+ if (argv.json) args.json = true;
5754
5773
  if (argv.code) args.code = argv.code;
5755
5774
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
5756
5775
  if (argv["user-id"]) args.userId = argv["user-id"];
@@ -6010,6 +6029,48 @@ async function main(): Promise<void> {
6010
6029
  console.log(`private: ${ch.is_private === true}`);
6011
6030
  },
6012
6031
  )
6032
+ .command(
6033
+ "invite <channel> <users..>",
6034
+ "Invite people to a channel (conversations.invite; confirm-hash gate). Acts as the current identity, e.g. slack @bot channel invite …",
6035
+ (yy) => yy
6036
+ .positional("channel", { type: "string", demandOption: true, describe: "#name, channel id, or permalink" })
6037
+ .positional("users", { type: "string", array: true, demandOption: true, describe: "@handle or U… ids" })
6038
+ .option("code", { type: "string", describe: "Safety hash to confirm" }),
6039
+ async (argv) => {
6040
+ const token = tok(argv as W);
6041
+ const cookie = ck(argv as W);
6042
+ let channel: string;
6043
+ const ids: string[] = [];
6044
+ try {
6045
+ channel = await resolveChannel(token, String(argv.channel), cookie);
6046
+ for (const ref of (argv.users as string[]).map(String)) ids.push(/^[UW][A-Z0-9]{2,}$/.test(ref) ? ref : await resolveUserId(token, ref, cookie));
6047
+ } catch (e: unknown) {
6048
+ console.error(friendlySlackError(e));
6049
+ process.exit(1);
6050
+ }
6051
+ const self = await selfIdentity(token, cookie);
6052
+ const label = await destLabel(token, channel, String(argv.channel), cookie);
6053
+ const code = safetyCode("channel-invite", channel, ids.join(","), self?.userId ?? "");
6054
+ if (argv.code !== code) requireCode(argv.code, code, [
6055
+ `--- Inviting to a channel --------------------`,
6056
+ fromLine(self, { pad: 9 }),
6057
+ ` channel: ${label}`,
6058
+ ` users: ${ids.join(", ")}`,
6059
+ `--------------------------------────────────`,
6060
+ ]);
6061
+ try {
6062
+ await inviteToChannel(token, channel, ids, cookie);
6063
+ } catch (e: unknown) {
6064
+ const raw = e instanceof Error ? e.message : String(e);
6065
+ // Inviting someone already there is not a failure worth exit 1.
6066
+ if (!/already_in_channel/.test(raw)) {
6067
+ console.error(`✗ invite failed: ${friendlySlackError(e)}${/not_in_channel/.test(raw) ? "\n The inviter must be a member of the channel first." : ""}`);
6068
+ process.exit(1);
6069
+ }
6070
+ }
6071
+ console.log(`✓ Invited ${ids.length} to ${label}`);
6072
+ },
6073
+ )
6013
6074
  .command(
6014
6075
  ["create <name>", "new <name>"],
6015
6076
  "Create a channel (confirm-hash safety gate)",
@@ -6349,7 +6410,7 @@ async function main(): Promise<void> {
6349
6410
  )
6350
6411
  .command(
6351
6412
  "send <target> <message>",
6352
- "Send a message (confirm-hash safety gate)",
6413
+ "Send a message (confirm-hash safety gate); - as the message reads stdin",
6353
6414
  (y) => y
6354
6415
  .positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
6355
6416
  .positional("message", { type: "string", demandOption: true })
@@ -6361,6 +6422,7 @@ async function main(): Promise<void> {
6361
6422
  .option("broadcast", { type: "boolean", default: false, describe: "Also send to channel: broadcast a threaded reply back to the channel (Slack's \"Also send to #channel\" checkbox). Only effective with a thread target." })
6362
6423
  .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real <@USERID> mentions (on by default; resolves via users.list, then channel members for Slack Connect guests). Unresolved handles stay as plain text. The confirm preview shows the converted message before sending. Disable with --no-mentions for literal @text." })
6363
6424
  .option("to", { type: "string", describe: "Permalink of the message you are answering: refuses when it is not in the target's thread (see also `slack reply --to`)" })
6425
+ .option("json", { type: "boolean", default: false, describe: "On success print one JSON line: {ok, channel, ts, thread_ts?, permalink?}" })
6364
6426
  .option("any-thread", { type: "boolean", default: false, describe: "With --to: send even when the message is in another thread" }),
6365
6427
  async (argv) => {
6366
6428
  if (argv.to && !argv["any-thread"]) {
@@ -6525,6 +6587,7 @@ async function main(): Promise<void> {
6525
6587
  (y) => y
6526
6588
  .positional("dest", { type: "string", describe: "alias | permalink | #chan:<ts> | <ts> (in settings' channel). Omit with --to" })
6527
6589
  .positional("message", { type: "string", describe: "Text, or - for stdin" })
6590
+ .option("json", { type: "boolean", default: false, describe: "On success print one JSON line: {ok, channel, ts, thread_ts, permalink?}" })
6528
6591
  .option("to", { type: "string", describe: "Permalink of the message you are answering: replies in ITS thread; with a dest, refuses when they differ" })
6529
6592
  .option("any-thread", { type: "boolean", default: false, describe: "With --to and a dest: post to dest even when the message is in another thread" })
6530
6593
  .option("prefix", { type: "string", describe: "Override settings' prefix (\"\" for none)" })
@@ -6538,7 +6601,7 @@ async function main(): Promise<void> {
6538
6601
  let message = argv.message;
6539
6602
  // `slack reply --to <link> 'text'`: the one positional is the text.
6540
6603
  if (argv.to && message === undefined) { message = dest; dest = undefined; }
6541
- if (message === "-") message = await Bun.stdin.text();
6604
+ if (isStdinArg(message)) message = await stdinText();
6542
6605
  if (message === undefined || !message.trim()) { console.error("Error: message is empty"); process.exit(1); }
6543
6606
  if (!dest && !argv.to) { console.error("Error: reply needs a destination (alias, permalink, #chan:<ts>) or --to <permalink>"); process.exit(2); }
6544
6607
  const token = tok(argv as W);
@@ -6566,7 +6629,7 @@ async function main(): Promise<void> {
6566
6629
  await runSend({
6567
6630
  workspace: (argv as W).workspace, target: `${channel}:${threadTs}`, message: text, code: argv.code,
6568
6631
  "allow-url-adjacent": argv["allow-url-adjacent"], "as-bot": argv["as-bot"] ?? settings.as_bot ?? false,
6569
- broadcast: argv.broadcast, mentions: argv.mentions,
6632
+ broadcast: argv.broadcast, mentions: argv.mentions, json: argv.json,
6570
6633
  });
6571
6634
  },
6572
6635
  )
@@ -6677,7 +6740,7 @@ async function main(): Promise<void> {
6677
6740
  .positional("message", { type: "string", demandOption: true })
6678
6741
  .option("in-reply-to", { type: "string", describe: "ts of the inbox message this answers: archived once sent" }),
6679
6742
  async (argv) => {
6680
- const text = argv.message === "-" ? await Bun.stdin.text() : argv.message;
6743
+ const text = isStdinArg(argv.message) ? await stdinText() : argv.message;
6681
6744
  if (!text.trim()) { console.error("Error: message is empty"); process.exit(1); }
6682
6745
  const r = addOutbox(findScope(), { to: argv.dest, text, ...(argv["in-reply-to"] ? { in_reply_to: argv["in-reply-to"] } : {}) });
6683
6746
  if (r.redacted.length) console.error(`⚠ redacted before queueing: ${r.redacted.join(", ")}`);
@@ -7783,7 +7846,8 @@ async function main(): Promise<void> {
7783
7846
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
7784
7847
  .option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
7785
7848
  .option("channel-id", { type: "string" })
7786
- .option("user-id", { type: "string" }),
7849
+ .option("user-id", { type: "string" })
7850
+ .option("as-bot", { type: "boolean", default: false, describe: "Upload via the bot token (xoxb / SLACK_BOT_TOKEN); `slack @bot upload …` does this too" }),
7787
7851
  async (argv) => {
7788
7852
  const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
7789
7853
  if (argv.title) args.title = argv.title;
@@ -7792,6 +7856,12 @@ async function main(): Promise<void> {
7792
7856
  if (argv.code) args.code = argv.code;
7793
7857
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
7794
7858
  if (argv["user-id"]) args.userId = argv["user-id"];
7859
+ if (argv["as-bot"]) {
7860
+ const bot = resolveBotToken();
7861
+ if (!bot) { console.error("Error: --as-bot needs a bot token (SLACK_BOT_TOKEN)"); process.exit(1); }
7862
+ await cmdUpload(bot, args);
7863
+ return;
7864
+ }
7795
7865
  const cookie = ck(argv as W);
7796
7866
  if (cookie) args.cookie = cookie;
7797
7867
  await cmdUpload(tok(argv as W), args);
package/ts/stream.ts CHANGED
@@ -37,7 +37,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileS
37
37
  import { dirname } from "node:path";
38
38
  import type { Cursor, Doorbell, Subscribe } from "./relay.ts";
39
39
  import { RelayAuthError } from "./relay.ts";
40
- import { history, RateLimitError, repliesPage, userConversations, userName, type Json } from "./slack.ts";
40
+ import { conversationInfo, history, RateLimitError, repliesPage, userConversations, userName, type Json } from "./slack.ts";
41
41
 
42
42
  export type ChannelRef = { id: string; name: string; isIm: boolean; isMpim?: boolean; user?: string };
43
43
 
@@ -45,7 +45,10 @@ export type Page = { messages: Record<string, Json>[]; nextCursor?: string };
45
45
 
46
46
  /** The Slack surface the stream needs — injected so tests run without HTTP. */
47
47
  export type StreamClient = {
48
- listChannels(): Promise<ChannelRef[]>;
48
+ /** Every conversation the identity is in — or, given `only`, just those ids
49
+ * (no users.conversations: a bot with im:read but not mpim:read or
50
+ * channels:read can still stream the DM it was named). */
51
+ listChannels(only?: string[]): Promise<ChannelRef[]>;
49
52
  history(channel: string, oldest: string, cursor?: string): Promise<Page>;
50
53
  replies(channel: string, threadTs: string, oldest: string, cursor?: string): Promise<Page>;
51
54
  userName(id: string): Promise<string>;
@@ -62,8 +65,37 @@ function page(resp: Json): Page {
62
65
  /** The real client: Slack Web API calls as the given identity. */
63
66
  export function webClient(token: string, cookie?: string): StreamClient {
64
67
  return {
65
- async listChannels() {
66
- const raw = await userConversations(token, "public_channel,private_channel,im,mpim", cookie);
68
+ async listChannels(only) {
69
+ let raw: Json[];
70
+ if (only) {
71
+ raw = [];
72
+ for (const id of only) {
73
+ const r = (await conversationInfo(token, id, cookie)) as { channel?: Json };
74
+ // conversations.info also answers for a public channel the identity is NOT in;
75
+ // leave it out, so refresh still refuses it as "not a member".
76
+ const ch = r.channel as { is_member?: boolean } | undefined;
77
+ if (ch && ch.is_member !== false) raw.push(r.channel!);
78
+ }
79
+ } else {
80
+ try {
81
+ raw = await userConversations(token, "public_channel,private_channel,im,mpim", cookie);
82
+ } catch (e) {
83
+ if (!/missing_scope/.test(e instanceof Error ? e.message : String(e))) throw e;
84
+ // One missing *:read scope fails the whole call: list type by type,
85
+ // keeping what this token may read.
86
+ raw = [];
87
+ const skipped: string[] = [];
88
+ for (const t of ["public_channel", "private_channel", "im", "mpim"]) {
89
+ try {
90
+ raw.push(...await userConversations(token, t, cookie));
91
+ } catch (e2) {
92
+ if (!/missing_scope/.test(e2 instanceof Error ? e2.message : String(e2))) throw e2;
93
+ skipped.push(t);
94
+ }
95
+ }
96
+ _internals.err(`slack stream: no scope to list ${skipped.join(", ")} — watching the rest`);
97
+ }
98
+ }
67
99
  return raw.flatMap((c): ChannelRef[] => {
68
100
  if (!c || typeof c !== "object" || Array.isArray(c) || typeof c.id !== "string") return [];
69
101
  const isIm = c.is_im === true;
@@ -601,7 +633,7 @@ export function paced(client: StreamClient, perMin: number, signal?: AbortSignal
601
633
  if (wait > 0) await _internals.sleep(wait, signal);
602
634
  };
603
635
  return {
604
- listChannels: () => client.listChannels(),
636
+ listChannels: (only) => client.listChannels(only),
605
637
  userName: (id) => client.userName(id),
606
638
  history: async (c, oldest, cursor) => { await pace(); return client.history(c, oldest, cursor); },
607
639
  replies: async (c, t, oldest, cursor) => { await pace(); return client.replies(c, t, oldest, cursor); },
@@ -687,7 +719,7 @@ export async function runStream(client: StreamClient, opts: StreamOpts): Promise
687
719
  };
688
720
 
689
721
  const refresh = async (nowSec: number): Promise<void> => {
690
- const listed = await client.listChannels();
722
+ const listed = await client.listChannels(opts.channels);
691
723
  channels = listed.filter((c) => !opts.channels || opts.channels.includes(c.id));
692
724
  if (opts.channels) {
693
725
  for (const id of opts.channels) {