slack-term 1.49.1 → 1.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/identity.ts ADDED
@@ -0,0 +1,152 @@
1
+ // `slack @<who> <command> …` — name the identity a command acts as (11-02):
2
+ // a bot (`@deploy-bot`, `@ops-bot`) or a person (`@alice`). Replaces --as-bot /
3
+ // --as-user and env-file juggling. The names map to WHERE a token lives (an
4
+ // env var, optionally in an env file, or a slack-term profile) — never to the
5
+ // token itself, which stays in the env/vault:
6
+ //
7
+ // identities: # .slack-term/settings.yml or ~/.config/slack-term/settings.yml
8
+ // deploy-bot: { kind: bot, env_file: ~/.config/slack-cli/deploy-bot.env } # SLACK_BOT_TOKEN in it
9
+ // ops-bot: { kind: bot, env_file: ~/.config/slack-cli/ops-bot.env }
10
+ // alice: { kind: user, profile: acme } # slack auth profile
11
+ //
12
+ // And the trap it closes (11-02: an agent's `slack ask` without --as-bot was
13
+ // posted AS a person): a write that would go out as a person's session is refused
14
+ // from inside an agent, or when several identities exist, unless it names one.
15
+ import { readFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { findScope, globalScopeDir, readSettingsFile, type Scope } from "./scope.ts";
19
+
20
+ export type Identity = {
21
+ kind: "bot" | "user";
22
+ /** The env var holding the token (default SLACK_BOT_TOKEN for a bot, SLACK_TOKEN for a user). */
23
+ env?: string;
24
+ /** An env file to read that var from; else the process environment. */
25
+ env_file?: string;
26
+ /** A user: a slack-term workspace profile (`slack auth ls`). */
27
+ profile?: string;
28
+ };
29
+
30
+ const WHO_RE = /^@([a-z0-9][a-z0-9._-]{0,63})$/;
31
+
32
+ /** `@who` as the first argument: its name and the arguments after it. */
33
+ export function splitWho(args: string[]): { who?: string; rest: string[] } {
34
+ const m = WHO_RE.exec(args[0] ?? "");
35
+ return m ? { who: m[1]!, rest: args.slice(1) } : { rest: args };
36
+ }
37
+
38
+ function asIdentity(v: unknown): Identity | undefined {
39
+ const o = v as Partial<Identity> | null;
40
+ if (!o || typeof o !== "object" || (o.kind !== "bot" && o.kind !== "user")) return undefined;
41
+ const id: Identity = { kind: o.kind };
42
+ if (typeof o.env === "string" && /^[A-Z_][A-Z0-9_]*$/.test(o.env)) id.env = o.env;
43
+ if (typeof o.env_file === "string") id.env_file = o.env_file;
44
+ if (typeof o.profile === "string") id.profile = o.profile;
45
+ return id;
46
+ }
47
+
48
+ /** Identities from the nearest scope (settings.yml, then settings.local.yml
49
+ * unless restricted), then the global one; nearer wins. */
50
+ export function listIdentities(cwd = process.cwd()): Map<string, Identity> {
51
+ const near = findScope(cwd);
52
+ const scopes: Scope[] = near.global ? [near] : [{ dir: globalScopeDir(), global: true }, near];
53
+ const out = new Map<string, Identity>();
54
+ for (const s of scopes) {
55
+ const base = readSettingsFile(join(s.dir, "settings.yml"));
56
+ const files = base.restrict_to_subscriptions ? ["settings.yml"] : ["settings.yml", "settings.local.yml"];
57
+ for (const f of files) {
58
+ let raw: unknown;
59
+ try {
60
+ raw = Bun.YAML.parse(readFileSync(join(s.dir, f), "utf8"));
61
+ } catch { continue; }
62
+ const ids = (raw as { identities?: Record<string, unknown> } | null)?.identities;
63
+ if (!ids || typeof ids !== "object") continue;
64
+ for (const [name, v] of Object.entries(ids)) {
65
+ const id = asIdentity(v);
66
+ if (id && WHO_RE.test(`@${name}`)) out.set(name, id);
67
+ }
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** KEY=value lines (quotes stripped); `export ` prefixes allowed. */
74
+ function readEnvFile(path: string): Record<string, string> {
75
+ const out: Record<string, string> = {};
76
+ for (const line of readFileSync(path.replace(/^~(?=\/|$)/, homedir()), "utf8").split("\n")) {
77
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
78
+ if (!m) continue;
79
+ let v = m[2]!;
80
+ if (/^(["']).*\1$/.test(v)) v = v.slice(1, -1);
81
+ out[m[1]!] = v;
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** Point this process at identity `who`: the token goes into the env the
87
+ * rest of the CLI already reads (never printed). Returns its kind, or an
88
+ * error naming the known identities. */
89
+ export function applyIdentity(who: string, env: NodeJS.ProcessEnv = process.env, ids = listIdentities()): { kind: "bot" | "user" } | { error: string } {
90
+ const id = ids.get(who);
91
+ const known = [...ids.keys()].sort().map((n) => `@${n}`).join(", ") || "(none: add `identities:` to .slack-term/settings.yml)";
92
+ if (!id) return { error: `unknown identity @${who}. Known: ${known}` };
93
+ if (id.kind === "user" && id.profile && !id.env && !id.env_file) {
94
+ env.SLACK_WORKSPACE = id.profile;
95
+ delete env.SLACK_TOKEN;
96
+ delete env.SLACK_BOT_TOKEN;
97
+ env.SLACK_TERM_IDENTITY = who;
98
+ return { kind: "user" };
99
+ }
100
+ const name = id.env ?? (id.kind === "bot" ? "SLACK_BOT_TOKEN" : "SLACK_TOKEN");
101
+ let token: string | undefined;
102
+ try {
103
+ token = id.env_file ? readEnvFile(id.env_file)[name] : env[name];
104
+ } catch {
105
+ return { error: `@${who}: cannot read ${id.env_file}` };
106
+ }
107
+ if (!token) return { error: `@${who}: no ${name}${id.env_file ? ` in ${id.env_file}` : " in the environment"}` };
108
+ if (id.kind === "bot" && !token.startsWith("xoxb-")) return { error: `@${who} is a bot, but ${name} is not a bot token (xoxb-)` };
109
+ if (id.kind === "user" && token.startsWith("xoxb-")) return { error: `@${who} is a user, but ${name} holds a bot token` };
110
+ if (id.kind === "bot") {
111
+ env.SLACK_BOT_TOKEN = token;
112
+ env.SLACK_TOKEN = token; // reads, too, go out as the bot
113
+ } else {
114
+ env.SLACK_TOKEN = token;
115
+ delete env.SLACK_BOT_TOKEN;
116
+ }
117
+ env.SLACK_TERM_IDENTITY = who;
118
+ return { kind: id.kind };
119
+ }
120
+
121
+ /** Set when this process runs inside an agent (ay, Claude Code, …). */
122
+ export function inAgent(env: NodeJS.ProcessEnv = process.env): string | undefined {
123
+ return ["AY_AGENT_ID", "AGENT_YES_PID", "CLAUDECODE"].find((k) => env[k]);
124
+ }
125
+
126
+ /** Why a write that would post as a PERSON's session is refused, or null.
127
+ * Allowed when it names an identity (`@who`), says --as-bot or --as-user, or
128
+ * the active token is a bot's anyway. */
129
+ export function userSessionRefusal(o: {
130
+ isWrite: boolean; who?: string | undefined; asBot: boolean; asUser: boolean; activeIsBot: boolean;
131
+ agent?: string | undefined; identities: string[]; cmd: string;
132
+ }): string | null {
133
+ if (!o.isWrite || o.who || o.asBot || o.asUser || o.activeIsBot) return null;
134
+ const names = o.identities.map((n) => `@${n}`).join(" | ");
135
+ const pick = names ? ` Name who acts: slack ${o.identities[0] ? `@${o.identities[0]}` : "@<who>"} ${o.cmd} … (${names})` : " Add identities to .slack-term/settings.yml, then: slack @<who> " + o.cmd + " …";
136
+ if (o.agent) {
137
+ return [
138
+ `Error: refusing to post as the user session from inside an agent (${o.agent} is set): \`${o.cmd}\` would go out as a PERSON.`,
139
+ pick,
140
+ " As the app: add --as-bot",
141
+ " As the person, on purpose: add --as-user",
142
+ ].join("\n");
143
+ }
144
+ if (o.identities.length >= 2) {
145
+ return [
146
+ `Error: several identities are configured (${names}): name the one \`${o.cmd}\` acts as.`,
147
+ pick,
148
+ " Or: --as-bot / --as-user",
149
+ ].join("\n");
150
+ }
151
+ return null;
152
+ }
package/ts/relay.ts CHANGED
@@ -18,6 +18,10 @@ export type Doorbell = {
18
18
  channel: string; ts: string; thread_ts?: string;
19
19
  /** The relay app's bot user id, on that bot's own posts only. */
20
20
  self?: string;
21
+ /** A reaction_added on the message at channel/ts (relays from 11-02 on). */
22
+ kind?: "reaction";
23
+ /** Who reacted (reaction bells only). */
24
+ user?: string;
21
25
  };
22
26
 
23
27
  /** First frame of every connection. `seq` is the resume point the relay
package/ts/relayInit.ts CHANGED
@@ -95,7 +95,8 @@ export function nextSteps(name: string, token?: string): string {
95
95
  4. In the Slack app -> Event Subscriptions: Enable Events, Request URL
96
96
  https://${name}.<your-subdomain>.workers.dev/slack/events
97
97
  and subscribe to bot events: message.channels, message.groups, message.im,
98
- app_mention (and message.mpim if the bot has mpim:history). Save; reinstall if asked.
98
+ app_mention (and message.mpim if the bot has mpim:history; reaction_added,
99
+ with reactions:read, for \`slack stream --reactions\`). Save; reinstall if asked.
99
100
  5. Where \`slack stream\` runs:
100
101
  export SLACK_RELAY_URL=https://${name}.<your-subdomain>.workers.dev
101
102
  export SLACK_RELAY_TOKEN=${tok}
package/ts/sentlog.ts CHANGED
@@ -290,7 +290,7 @@ export function recordSent(r: RecordSent): boolean {
290
290
  if (!attributionEnabled()) return false;
291
291
  const db = openSentLog();
292
292
  if (!db) {
293
- console.error(` (送信ログに記録できませんでした: ${SENT_LOG_UNAVAILABLE} — ${sentDbPath()})`);
293
+ console.error(` (could not record to the sent log: ${SENT_LOG_UNAVAILABLE} — ${sentDbPath()})`);
294
294
  return false;
295
295
  }
296
296
  try {
@@ -305,7 +305,7 @@ export function recordSent(r: RecordSent): boolean {
305
305
  );
306
306
  return true;
307
307
  } catch (e: unknown) {
308
- console.error(` (送信ログに記録できませんでした: ${e instanceof Error ? e.message : String(e)})`);
308
+ console.error(` (could not record to the sent log: ${e instanceof Error ? e.message : String(e)})`);
309
309
  return false;
310
310
  } finally {
311
311
  try { db.close(); } catch { /* ignore */ }
@@ -330,7 +330,7 @@ export interface SentQuery {
330
330
  * needs bun or node ≥ 22.5 (`node:sqlite`). Surfaced by `slack sent`, which
331
331
  * must not answer "no matches" when the truth is "no log". */
332
332
  export const SENT_LOG_UNAVAILABLE =
333
- "送信ログを開けません: SQLite が使えないランタイムです (bun、または node >= 22.5 の node:sqlite が必要)";
333
+ "cannot open the sent log: this runtime has no SQLite (needs bun, or node >= 22.5 for node:sqlite)";
334
334
 
335
335
  /** Newest first, or `undefined` when the log cannot be opened at all. */
336
336
  export function querySent(q: SentQuery): SentRow[] | undefined {
package/ts/stream.ts CHANGED
@@ -136,6 +136,10 @@ export type StreamOpts = {
136
136
  selfUsers: Set<string>;
137
137
  selfBots: Set<string>;
138
138
  json: boolean;
139
+ /** Print reaction bells (`slack stream --reactions`): {type: "reaction",
140
+ * channel, ts, user} — the emoji itself is read with reactions.get by
141
+ * whoever needs it. Without it they are dropped unread. */
142
+ reactions?: boolean;
139
143
  /** Also called with every emitted match and its attachment count (`slack stream --inbox`). */
140
144
  onMatch?: (rec: StreamMatch, files: number) => void;
141
145
  once: boolean;
@@ -822,6 +826,14 @@ export async function runStream(client: StreamClient, opts: StreamOpts): Promise
822
826
  /** true = resolved (emitted, or nothing to emit); false = not visible yet. */
823
827
  const handleBell = async (bell: Doorbell): Promise<boolean> => {
824
828
  if (opts.channels && !opts.channels.includes(bell.channel)) return true;
829
+ // A reaction bell never costs a message read: printed as it is, or dropped.
830
+ if (bell.kind === "reaction") {
831
+ if (!opts.reactions || (bell.self && opts.selfUsers.has(bell.self))) return true;
832
+ if (bell.user && opts.selfUsers.has(bell.user)) return true;
833
+ const rec = { type: "reaction" as const, channel: { id: bell.channel }, ts: bell.ts, user: { id: bell.user ?? "" } };
834
+ _internals.out(opts.json ? JSON.stringify(rec) : `reaction ${bell.channel} ${bell.ts} by ${bell.user ?? "?"}`);
835
+ return true;
836
+ }
825
837
  let ch = channels.find((c) => c.id === bell.channel);
826
838
  if (!ch) {
827
839
  // A channel joined since the last refresh. Re-list at most once a
@@ -12,6 +12,10 @@ export type Doorbell = {
12
12
  * skip the Web API read the bell would cost. Nothing about any other
13
13
  * sender is kept. */
14
14
  self?: string;
15
+ /** A reaction_added on the message at channel/ts, by `user` — still only
16
+ * ids: which emoji is read from Slack by whoever needs it. */
17
+ kind?: "reaction";
18
+ user?: string;
15
19
  };
16
20
 
17
21
  /** Slack rejects replays older than five minutes; so do we. */
@@ -112,6 +116,14 @@ export function doorbellOf(payload: unknown, teamId?: string): Doorbell | null {
112
116
  if (p.type !== "event_callback" || !p.event || typeof p.event !== "object") return null;
113
117
  if (teamId && p.team_id !== teamId) return null;
114
118
  const e = p.event;
119
+ if (e.type === "reaction_added") {
120
+ const item = e.item as Record<string, unknown> | undefined;
121
+ if (!item || item.type !== "message") return null; // file / file_comment reactions have no message ts
122
+ const { channel, ts } = item;
123
+ if (typeof channel !== "string" || !CHANNEL.test(channel) || typeof ts !== "string" || !TS.test(ts)) return null;
124
+ const self = selfPost(p, e);
125
+ return { channel, ts, kind: "reaction", ...(typeof e.user === "string" && USER.test(e.user) ? { user: e.user } : {}), ...(self ? { self } : {}) };
126
+ }
115
127
  if (e.type !== "message" && e.type !== "app_mention") return null;
116
128
  if (typeof e.subtype === "string" && SKIP_SUBTYPES.has(e.subtype)) return null;
117
129
  if (e.hidden === true) return null;