slack-term 1.34.0 → 1.35.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/dist/cli.js +117 -117
- package/package.json +1 -1
- package/ts/agent.ts +148 -0
- package/ts/cli.ts +3 -1
- package/ts/slack.ts +25 -0
package/package.json
CHANGED
package/ts/agent.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { constants } from "node:os";
|
|
3
|
+
import type { Argv } from "yargs";
|
|
4
|
+
import { resolveBotToken } from "./profiles.ts";
|
|
5
|
+
import { renameAgentSession, resolveChannel, setAgentStatus, type AgentStatus, type AgentThread } from "./slack.ts";
|
|
6
|
+
|
|
7
|
+
const statuses: AgentStatus[] = ["processing", "active", "suspended", "closed"];
|
|
8
|
+
type ParseTarget = (target: string) => { ref: string; threadTs?: string };
|
|
9
|
+
|
|
10
|
+
function botToken(envName?: string): string {
|
|
11
|
+
if (envName !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName)) {
|
|
12
|
+
throw new Error("--bot-token-env must name an environment variable, not contain a token.");
|
|
13
|
+
}
|
|
14
|
+
const token = envName === undefined ? resolveBotToken() : process.env[envName];
|
|
15
|
+
if (!token?.startsWith("xoxb-")) {
|
|
16
|
+
throw new Error("Agent commands require a bot token (xoxb). Set SLACK_BOT_TOKEN or --bot-token-env NAME; user tokens are refused.");
|
|
17
|
+
}
|
|
18
|
+
return token;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateTitle(title?: string): void {
|
|
22
|
+
if (title !== undefined && (!title.trim() || [...title].length > 200)) {
|
|
23
|
+
throw new Error("--title must contain 1–200 characters.");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function threadTarget(token: string, target: string, parse: ParseTarget): Promise<AgentThread> {
|
|
28
|
+
const { ref, threadTs } = parse(target);
|
|
29
|
+
if (!threadTs || !/^\d{10}\.\d{6}$/.test(threadTs)) {
|
|
30
|
+
throw new Error("Target must include a thread root: C00000001:1700000000.000100, #channel:ts, or a message permalink.");
|
|
31
|
+
}
|
|
32
|
+
return { channel_id: await resolveChannel(token, ref), thread_ts: threadTs };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function report(context: string, error: unknown): void {
|
|
36
|
+
console.error(`agent: ${context}: ${error instanceof Error ? error.message : "request failed"}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Own the command's lifetime, and serialize the last refresh before final cleanup. */
|
|
40
|
+
export async function runAgent(
|
|
41
|
+
token: string,
|
|
42
|
+
thread: AgentThread,
|
|
43
|
+
command: string[],
|
|
44
|
+
opts: { every: number; title?: string; close: boolean },
|
|
45
|
+
): Promise<number> {
|
|
46
|
+
let child: ChildProcess | undefined;
|
|
47
|
+
let signal: NodeJS.Signals | undefined;
|
|
48
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
49
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
50
|
+
let refreshing: Promise<void> | undefined;
|
|
51
|
+
let stopping = false;
|
|
52
|
+
let attempted = false;
|
|
53
|
+
let code = 1;
|
|
54
|
+
|
|
55
|
+
const stop = (received: NodeJS.Signals) => {
|
|
56
|
+
if (signal) return; // repeated interrupts must not bypass the final write
|
|
57
|
+
signal = received;
|
|
58
|
+
stopping = true;
|
|
59
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
60
|
+
child.kill(received);
|
|
61
|
+
killTimer = setTimeout(() => child?.kill("SIGKILL"), 5000);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const onInt = () => stop("SIGINT");
|
|
65
|
+
const onTerm = () => stop("SIGTERM");
|
|
66
|
+
process.on("SIGINT", onInt);
|
|
67
|
+
process.on("SIGTERM", onTerm);
|
|
68
|
+
try {
|
|
69
|
+
attempted = true; // even a lost response may have set processing remotely
|
|
70
|
+
await setAgentStatus(token, thread, "processing");
|
|
71
|
+
if (!stopping && opts.title !== undefined) await renameAgentSession(token, thread, opts.title);
|
|
72
|
+
if (!stopping) {
|
|
73
|
+
child = spawn(command[0]!, command.slice(1), { stdio: "inherit" });
|
|
74
|
+
const done = new Promise<number>((resolve) => {
|
|
75
|
+
child!.once("error", () => {
|
|
76
|
+
console.error("agent: could not start command");
|
|
77
|
+
resolve(127);
|
|
78
|
+
});
|
|
79
|
+
child!.once("exit", (exitCode, exitSignal) => {
|
|
80
|
+
resolve(exitCode ?? (exitSignal ? 128 + constants.signals[exitSignal] : 1));
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
timer = setInterval(() => {
|
|
84
|
+
if (stopping || refreshing) return;
|
|
85
|
+
refreshing = setAgentStatus(token, thread, "processing")
|
|
86
|
+
.then(() => {})
|
|
87
|
+
.catch((error: unknown) => report("heartbeat failed; retrying on next tick", error))
|
|
88
|
+
.finally(() => { refreshing = undefined; });
|
|
89
|
+
}, opts.every * 1000);
|
|
90
|
+
code = await done;
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
report("could not start agent session", error);
|
|
94
|
+
} finally {
|
|
95
|
+
stopping = true;
|
|
96
|
+
clearInterval(timer);
|
|
97
|
+
clearTimeout(killTimer);
|
|
98
|
+
// No late processing write may race with (and undo) the final active write.
|
|
99
|
+
await refreshing;
|
|
100
|
+
if (attempted) {
|
|
101
|
+
try {
|
|
102
|
+
await setAgentStatus(token, thread, opts.close ? "closed" : "active");
|
|
103
|
+
} catch (error) {
|
|
104
|
+
report("final status clear failed", error);
|
|
105
|
+
if (code === 0) code = 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
process.removeListener("SIGINT", onInt);
|
|
109
|
+
process.removeListener("SIGTERM", onTerm);
|
|
110
|
+
}
|
|
111
|
+
return signal ? 128 + constants.signals[signal] : code;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function agentCommands(y: Argv, parse: ParseTarget): Argv {
|
|
115
|
+
const common = (args: Argv) => args
|
|
116
|
+
.positional("target", { type: "string", demandOption: true, describe: "Thread root: raw channel ID:ts (avoids listing), #channel:ts, or permalink" })
|
|
117
|
+
.option("title", { type: "string", describe: "Rename the session (1–200 characters)" })
|
|
118
|
+
.option("bot-token-env", { type: "string", describe: "Environment variable holding an agent-enabled app's xoxb token (default SLACK_BOT_TOKEN); requires chat:write and channel membership" });
|
|
119
|
+
return y
|
|
120
|
+
.command("status <target> <status>", "Set a bot agent session's status", (args) => common(args)
|
|
121
|
+
.positional("status", { type: "string", choices: statuses, demandOption: true }), async (args) => {
|
|
122
|
+
validateTitle(args.title);
|
|
123
|
+
const token = botToken(args["bot-token-env"]);
|
|
124
|
+
const thread = await threadTarget(token, args.target, parse);
|
|
125
|
+
await setAgentStatus(token, thread, args.status as AgentStatus);
|
|
126
|
+
if (args.title !== undefined) await renameAgentSession(token, thread, args.title);
|
|
127
|
+
})
|
|
128
|
+
.command("run <target>", "Keep a thread working while a command runs: run <target> -- <command...>", (args) => common(args)
|
|
129
|
+
.parserConfiguration({ "populate--": true })
|
|
130
|
+
.option("every", { type: "number", default: 300, describe: "Heartbeat seconds (>0 and <3600); processing expires after one hour" })
|
|
131
|
+
.option("close", { type: "boolean", default: false, describe: "Finish with closed instead of active (also on failure or interrupt)" }), async (args) => {
|
|
132
|
+
validateTitle(args.title);
|
|
133
|
+
if (!Number.isFinite(args.every) || args.every <= 0 || args.every >= 3600) {
|
|
134
|
+
throw new Error("--every must be greater than 0 and less than 3600 seconds.");
|
|
135
|
+
}
|
|
136
|
+
// Preserve the exact argv, including numeric strings and the child's own --.
|
|
137
|
+
const separator = process.argv.indexOf("--");
|
|
138
|
+
const command = separator < 0 ? [] : process.argv.slice(separator + 1);
|
|
139
|
+
if (!command[0]) throw new Error("agent run requires -- <command...>");
|
|
140
|
+
const token = botToken(args["bot-token-env"]);
|
|
141
|
+
const thread = await threadTarget(token, args.target, parse);
|
|
142
|
+
process.exitCode = await runAgent(token, thread, command, {
|
|
143
|
+
every: args.every, close: args.close,
|
|
144
|
+
...(args.title !== undefined ? { title: args.title } : {}),
|
|
145
|
+
});
|
|
146
|
+
})
|
|
147
|
+
.demandCommand(1, "Choose agent status or agent run.");
|
|
148
|
+
}
|
package/ts/cli.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveTok
|
|
|
13
13
|
import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
|
|
14
14
|
import { cmdAuthLogin, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp } from "./auth.ts";
|
|
15
15
|
import { cmdTail } from "./tail.ts";
|
|
16
|
+
import { agentCommands } from "./agent.ts";
|
|
16
17
|
|
|
17
18
|
import {
|
|
18
19
|
ASK_KEYCAPS,
|
|
@@ -3283,7 +3284,7 @@ async function main(): Promise<void> {
|
|
|
3283
3284
|
.option("workspace", { alias: "w", type: "string", describe: "Workspace name" })
|
|
3284
3285
|
.middleware(async (argv) => {
|
|
3285
3286
|
const cmd = String((argv._ ?? [])[0] ?? "");
|
|
3286
|
-
if (!cmd || cmd === "auth" || cmd === "login") return;
|
|
3287
|
+
if (!cmd || cmd === "auth" || cmd === "login" || cmd === "agent") return;
|
|
3287
3288
|
try {
|
|
3288
3289
|
resolveToken((argv as W).workspace);
|
|
3289
3290
|
} catch (e) {
|
|
@@ -3296,6 +3297,7 @@ async function main(): Promise<void> {
|
|
|
3296
3297
|
throw e;
|
|
3297
3298
|
}
|
|
3298
3299
|
}, true)
|
|
3300
|
+
.command("agent", "Bot agent session status and command heartbeat", (y) => agentCommands(y, parseTargetThread))
|
|
3299
3301
|
.command(
|
|
3300
3302
|
["read [target]", "msgs [target]"],
|
|
3301
3303
|
"Browse messages",
|
package/ts/slack.ts
CHANGED
|
@@ -55,6 +55,9 @@ async function call(token: string, method: string, init: RequestInit, cookie?: s
|
|
|
55
55
|
// Bot tokens (xoxb-) can't act as a user: they lack user scopes (missing_scope) and can't
|
|
56
56
|
// open a DM with themselves (cannot_dm_bot, e.g. `tail @you`). Point at a user-token workspace.
|
|
57
57
|
if ((err === "missing_scope" || err === "cannot_dm_bot") && token.startsWith("xoxb-")) {
|
|
58
|
+
if (method.startsWith("agents.sessions.")) {
|
|
59
|
+
throw new Error(`Slack error on ${method}: ${err}. Agent sessions need a granular bot token with chat:write and app membership in the channel.`);
|
|
60
|
+
}
|
|
58
61
|
const why = err === "cannot_dm_bot"
|
|
59
62
|
? `you're authenticated as a bot, so "@you" is the bot itself and it can't DM itself`
|
|
60
63
|
: `this bot token lacks the user scope this action needs`;
|
|
@@ -132,6 +135,28 @@ function post(token: string, method: string, body: Record<string, Json>, cookie?
|
|
|
132
135
|
}, cookie);
|
|
133
136
|
}
|
|
134
137
|
|
|
138
|
+
// Bound session writes so a stalled heartbeat cannot block final cleanup forever.
|
|
139
|
+
async function agentPost(token: string, method: string, body: Record<string, Json>): Promise<Json> {
|
|
140
|
+
if (!token.startsWith("xoxb-")) throw new Error("Agent sessions require a bot token (xoxb); user tokens are refused.");
|
|
141
|
+
return call(token, method, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "Content-Type": "application/json" },
|
|
144
|
+
body: JSON.stringify(body),
|
|
145
|
+
signal: AbortSignal.timeout(10_000),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type AgentStatus = "processing" | "active" | "suspended" | "closed";
|
|
150
|
+
export type AgentThread = { channel_id: string; thread_ts: string };
|
|
151
|
+
|
|
152
|
+
export function setAgentStatus(token: string, thread: AgentThread, status: AgentStatus): Promise<Json> {
|
|
153
|
+
return agentPost(token, "agents.sessions.setStatus", { ...thread, status });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function renameAgentSession(token: string, thread: AgentThread, title: string): Promise<Json> {
|
|
157
|
+
return agentPost(token, "agents.sessions.rename", { ...thread, title });
|
|
158
|
+
}
|
|
159
|
+
|
|
135
160
|
export async function authTest(
|
|
136
161
|
token: string,
|
|
137
162
|
cookie?: string,
|