slack-term 1.49.0 → 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/README.md +26 -0
- package/dist/cli.js +272 -266
- package/package.json +1 -1
- package/ts/ask.ts +5 -5
- package/ts/botIntent.ts +1 -1
- package/ts/cli.ts +157 -114
- package/ts/identity.ts +152 -0
- package/ts/relay.ts +4 -0
- package/ts/relayInit.ts +2 -1
- package/ts/sentlog.ts +3 -3
- package/ts/stream.ts +12 -0
- package/worker/src/slack.ts +12 -0
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
|
|
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(` (
|
|
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(` (
|
|
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
|
-
"
|
|
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
|
package/worker/src/slack.ts
CHANGED
|
@@ -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;
|