roger-roger 0.1.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/package.json +45 -0
  4. package/skills/roger-roger/SKILL.md +289 -0
  5. package/skills/roger-roger/herdr-plugin.toml +38 -0
  6. package/skills/roger-roger/scripts/agent.mjs +132 -0
  7. package/skills/roger-roger/scripts/audio.mjs +392 -0
  8. package/skills/roger-roger/scripts/client.mjs +121 -0
  9. package/skills/roger-roger/scripts/daemon.mjs +604 -0
  10. package/skills/roger-roger/scripts/decisions.mjs +158 -0
  11. package/skills/roger-roger/scripts/handlers.mjs +1151 -0
  12. package/skills/roger-roger/scripts/herdr.mjs +140 -0
  13. package/skills/roger-roger/scripts/hooks-codex.mjs +154 -0
  14. package/skills/roger-roger/scripts/hooks-opencode.mjs +167 -0
  15. package/skills/roger-roger/scripts/hooks.mjs +420 -0
  16. package/skills/roger-roger/scripts/inbox.mjs +381 -0
  17. package/skills/roger-roger/scripts/install.mjs +560 -0
  18. package/skills/roger-roger/scripts/lib.mjs +1133 -0
  19. package/skills/roger-roger/scripts/names.mjs +84 -0
  20. package/skills/roger-roger/scripts/progress.mjs +91 -0
  21. package/skills/roger-roger/scripts/protocol.mjs +71 -0
  22. package/skills/roger-roger/scripts/roger-roger.mjs +536 -0
  23. package/skills/roger-roger/scripts/router.mjs +86 -0
  24. package/skills/roger-roger/scripts/sessions.mjs +218 -0
  25. package/skills/roger-roger/scripts/slack.mjs +240 -0
  26. package/skills/roger-roger/scripts/slackapp.mjs +205 -0
  27. package/skills/roger-roger/scripts/slackcli.mjs +144 -0
  28. package/skills/roger-roger/scripts/speaker.mjs +224 -0
  29. package/skills/roger-roger/scripts/speechkey.mjs +106 -0
  30. package/skills/roger-roger/scripts/tray.mjs +128 -0
  31. package/skills/roger-roger/scripts/tts.mjs +275 -0
  32. package/skills/roger-roger/scripts/tui.mjs +465 -0
  33. package/skills/roger-roger/slack/manifest.json +34 -0
  34. package/skills/roger-roger/sounds/alert.wav +0 -0
  35. package/skills/roger-roger/sounds/bubble.wav +0 -0
  36. package/skills/roger-roger/sounds/chime.wav +0 -0
  37. package/skills/roger-roger/sounds/ding.wav +0 -0
  38. package/skills/roger-roger/sounds/marimba.wav +0 -0
  39. package/skills/roger-roger/tray/main.mjs +749 -0
  40. package/skills/roger-roger/tray/panel.html +501 -0
@@ -0,0 +1,218 @@
1
+ // The register of agent sessions: who is running, what to call them, and what is waiting for them.
2
+ //
3
+ // Every CLI call carries a session id worked out by agent.mjs, so the separate processes one agent
4
+ // spawns are recognisably the same session, whatever agent it is. On first sight a session is given
5
+ // a colour nickname — sage, rose, plum — which is how the user sees it in Slack and how they address
6
+ // it in a message.
7
+ //
8
+ // Kept in ~/.roger-roger/sessions.json. The daemon is the only writer while it runs.
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { rogerRogerHome, pidAlive } from "./lib.mjs";
13
+ import { NAMES, colourOf, pickName } from "./names.mjs";
14
+
15
+ // Without an agent pid to check, a session counts as gone once it has been quiet this long.
16
+ const QUIET_MS = 30 * 60 * 1000;
17
+ // Even with a live pid, a session that hasn't said anything all day is no longer a sensible target.
18
+ const MAX_IDLE_MS = 12 * 3600 * 1000;
19
+ const FORGET_MS = 7 * 24 * 3600 * 1000;
20
+ const MAX_QUEUE = 50;
21
+
22
+ export const STATES = ["working", "waiting", "paused", "done"];
23
+
24
+ function file() {
25
+ return path.join(rogerRogerHome(), "sessions.json");
26
+ }
27
+
28
+ export function load() {
29
+ try {
30
+ const raw = JSON.parse(fs.readFileSync(file(), "utf8"));
31
+ return { sessions: raw.sessions ?? {}, threads: raw.threads ?? {}, aliases: raw.aliases ?? {} };
32
+ } catch {
33
+ return { sessions: {}, threads: {}, aliases: {} };
34
+ }
35
+ }
36
+
37
+ export function save(state) {
38
+ fs.mkdirSync(path.dirname(file()), { recursive: true });
39
+ const tmp = `${file()}.${process.pid}.tmp`;
40
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
41
+ fs.renameSync(tmp, file());
42
+ }
43
+
44
+ function update(fn) {
45
+ const state = load();
46
+ const result = fn(state);
47
+ save(prune(state));
48
+ return result;
49
+ }
50
+
51
+ /** Drop sessions nobody has heard from in a week, and thread bindings whose session went with them. */
52
+ function prune(state, now = Date.now()) {
53
+ for (const [id, s] of Object.entries(state.sessions)) {
54
+ if (now - Date.parse(s.lastSeen ?? 0) > FORGET_MS) delete state.sessions[id];
55
+ }
56
+ for (const [ts, id] of Object.entries(state.threads)) {
57
+ if (!state.sessions[id]) delete state.threads[ts];
58
+ }
59
+ for (const [fingerprint, id] of Object.entries(state.aliases ?? {})) {
60
+ if (!state.sessions[id]) delete state.aliases[fingerprint];
61
+ }
62
+ return state;
63
+ }
64
+
65
+ /** Is this session still someone an agent could be listening on? */
66
+ export function isLive(session, now = Date.now()) {
67
+ if (!session || session.endedAt) return false;
68
+ const idle = now - Date.parse(session.lastSeen ?? 0);
69
+ if (idle > MAX_IDLE_MS) return false;
70
+ return session.pid ? pidAlive(session.pid) : idle < QUIET_MS;
71
+ }
72
+
73
+ /**
74
+ * Record that a session is active now, giving it a nickname the first time it is seen. `identity`
75
+ * comes from the CLI (id, pid, cwd, project, host); `about` is what the agent told us about itself
76
+ * (--session name, model, agent). Returns the session.
77
+ */
78
+ export function register(identity, about = {}, pool = NAMES) {
79
+ return update((state) => {
80
+ const now = new Date().toISOString();
81
+ state.aliases ??= {};
82
+ // An agent that publishes nothing is recognised by its terminal: once a command from it has
83
+ // said which session it belongs to, later commands that leave `--session` off still land there.
84
+ const fingerprint = identity.fingerprint ?? "";
85
+ const id = identity.source === "terminal" && state.aliases[fingerprint]
86
+ ? state.aliases[fingerprint]
87
+ : identity.id;
88
+ if (fingerprint) state.aliases[fingerprint] = id;
89
+ const existing = state.sessions[id];
90
+ const taken = Object.values(state.sessions)
91
+ .filter((s) => s.id !== id && isLive(s))
92
+ .map((s) => s.nickname);
93
+ const session = {
94
+ ...existing,
95
+ id,
96
+ // A session keeps its name, unless someone else was given it while this one was quiet — two
97
+ // live agents answering to "jade" is exactly what the names are there to prevent.
98
+ nickname: existing?.nickname && !taken.includes(existing.nickname) ? existing.nickname : pickName(taken, pool),
99
+ pid: identity.pid || existing?.pid || 0,
100
+ cwd: identity.cwd ?? existing?.cwd ?? "",
101
+ project: identity.project || existing?.project || "",
102
+ host: identity.host ?? existing?.host ?? "",
103
+ attended: identity.attended ?? existing?.attended ?? false,
104
+ // The Herdr pane it runs in, and what its tab and agent were last labelled (herdr.mjs).
105
+ herdr: identity.herdr ?? existing?.herdr ?? null,
106
+ herdrLabel: existing?.herdrLabel ?? null,
107
+ name: about.name ?? existing?.name ?? "",
108
+ model: about.model ?? existing?.model ?? "",
109
+ agent: about.agent || existing?.agent || identity.agent || "",
110
+ source: identity.source ?? existing?.source ?? "",
111
+ state: about.state ?? existing?.state ?? "working",
112
+ firstSeen: existing?.firstSeen ?? now,
113
+ lastSeen: now,
114
+ queue: existing?.queue ?? [],
115
+ endedAt: null,
116
+ };
117
+ state.sessions[id] = session;
118
+ return session;
119
+ });
120
+ }
121
+
122
+ export function get(id) {
123
+ return load().sessions[id] ?? null;
124
+ }
125
+
126
+ export function byNickname(nickname) {
127
+ const want = String(nickname ?? "").toLowerCase();
128
+ return Object.values(load().sessions).find((s) => s.nickname === want) ?? null;
129
+ }
130
+
131
+ /** Every live session, oldest first, with its colour resolved. */
132
+ export function live(now = Date.now()) {
133
+ return Object.values(load().sessions)
134
+ .filter((s) => isLive(s, now))
135
+ .sort((a, b) => Date.parse(a.firstSeen) - Date.parse(b.firstSeen))
136
+ .map(decorate);
137
+ }
138
+
139
+ export function all() {
140
+ return Object.values(load().sessions)
141
+ .sort((a, b) => Date.parse(b.lastSeen) - Date.parse(a.lastSeen))
142
+ .map(decorate);
143
+ }
144
+
145
+ /** A session as the rest of the code wants it: with its colour, swatch and live flag. */
146
+ export function decorate(session, now = Date.now()) {
147
+ const colour = colourOf(session.nickname);
148
+ return { ...session, colour: colour.hex, swatch: colour.swatch, live: isLive(session, now) };
149
+ }
150
+
151
+ export function touch(id, patch = {}) {
152
+ return update((state) => {
153
+ const session = state.sessions[id];
154
+ if (!session) return null;
155
+ Object.assign(session, patch, { lastSeen: new Date().toISOString() });
156
+ return session;
157
+ });
158
+ }
159
+
160
+ /** The agent says it is finished; it stops being a routing target straight away. */
161
+ export function end(id) {
162
+ return update((state) => {
163
+ const session = state.sessions[id];
164
+ if (!session) return null;
165
+ session.endedAt = new Date().toISOString();
166
+ session.state = "done";
167
+ return session;
168
+ });
169
+ }
170
+
171
+ /** Give a session a different nickname (`sessions rename sage plum`). */
172
+ export function rename(id, nickname) {
173
+ return update((state) => {
174
+ const session = state.sessions[id];
175
+ if (!session) return null;
176
+ const clash = Object.values(state.sessions).find((s) => s.id !== id && s.nickname === nickname && isLive(s));
177
+ if (clash) throw new Error(`"${nickname}" is already taken by another running session`);
178
+ session.nickname = nickname;
179
+ return session;
180
+ });
181
+ }
182
+
183
+ /** Remember that a Slack thread belongs to a session, so replies in it need no picker. */
184
+ export function bindThread(ts, sessionId) {
185
+ if (!ts || !sessionId) return;
186
+ update((state) => {
187
+ state.threads[ts] = sessionId;
188
+ });
189
+ }
190
+
191
+ export function threads() {
192
+ return load().threads;
193
+ }
194
+
195
+ /** Hand a message to a session. It waits there until the agent next checks in. */
196
+ export function enqueue(id, message) {
197
+ return update((state) => {
198
+ const session = state.sessions[id];
199
+ if (!session) return null;
200
+ session.queue = [...(session.queue ?? []), message].slice(-MAX_QUEUE);
201
+ return session;
202
+ });
203
+ }
204
+
205
+ /** Everything waiting for a session, removed as it is handed over. */
206
+ export function drain(id) {
207
+ return update((state) => {
208
+ const session = state.sessions[id];
209
+ if (!session?.queue?.length) return [];
210
+ const queue = session.queue;
211
+ session.queue = [];
212
+ return queue;
213
+ });
214
+ }
215
+
216
+ export function pending(id) {
217
+ return get(id)?.queue ?? [];
218
+ }
@@ -0,0 +1,240 @@
1
+ // Slack Web API calls and a minimal Socket Mode client (Node's built-in WebSocket).
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { isUserId, slackAppToken, slackToken } from "./lib.mjs";
6
+
7
+ /**
8
+ * Blocks, optionally wrapped in an attachment so Slack draws a coloured bar down the left edge.
9
+ * That bar is a session's colour, which is what makes three agents in one DM tellable apart at a
10
+ * glance. Both keys are always sent, so an update can add or remove the bar.
11
+ *
12
+ * With an attachment, `text` becomes the attachment's `fallback` rather than the message's own
13
+ * text: Slack renders a top-level `text` *as well as* the attachment, which showed every coloured
14
+ * message twice. As a fallback it is still what a notification and an unsupported client show.
15
+ */
16
+ export function messageBody({ text, blocks, color }) {
17
+ return color
18
+ ? { text: "", blocks: [], attachments: [{ color, fallback: text, blocks }] }
19
+ : { text, blocks, attachments: [] };
20
+ }
21
+
22
+ /** Where the Slack API lives. Overridable so a test can stand one up and watch what we send. */
23
+ export const apiBase = () => (process.env.ROGER_ROGER_SLACK_API || "https://slack.com/api").replace(/\/$/, "");
24
+
25
+ export async function slackCall(method, token, payload) {
26
+ const res = await fetch(`${apiBase()}/${method}`, {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json; charset=utf-8", Authorization: `Bearer ${token}` },
29
+ body: JSON.stringify(payload),
30
+ signal: AbortSignal.timeout(15_000),
31
+ });
32
+ return res.json().catch(() => ({ ok: false, error: `http_${res.status}` }));
33
+ }
34
+
35
+ function botToken() {
36
+ const token = slackToken();
37
+ if (!token) throw new Error("SLACK_API_BOT_TOKEN is not set");
38
+ return token;
39
+ }
40
+
41
+ /** Post to the configured target. Returns the real channel ID and message ts. */
42
+ export async function postMessage(config, { text, blocks, color, thread_ts }) {
43
+ const token = botToken();
44
+ const target = config.slack.target;
45
+ if (!target) throw new Error("no Slack target configured");
46
+
47
+ const payload = { ...messageBody({ text, blocks, color }), ...(thread_ts ? { thread_ts } : {}), unfurl_links: false, unfurl_media: false };
48
+ let r = await slackCall("chat.postMessage", token, { ...payload, channel: target });
49
+ // Some workspaces only allow DMs to a user through an opened conversation.
50
+ if (!r.ok && isUserId(target) && ["channel_not_found", "not_in_channel"].includes(r.error)) {
51
+ const open = await slackCall("conversations.open", token, { users: target });
52
+ if (open.ok) r = await slackCall("chat.postMessage", token, { ...payload, channel: open.channel.id });
53
+ }
54
+ if (!r.ok) throw new Error(`Slack: ${r.error}`);
55
+ return { channel: r.channel, ts: r.ts };
56
+ }
57
+
58
+ export async function updateMessage(channel, ts, { text, blocks, color }) {
59
+ const r = await slackCall("chat.update", botToken(), { channel, ts, ...messageBody({ text, blocks, color }) });
60
+ if (!r.ok) throw Object.assign(new Error(`Slack: ${r.error}`), { code: r.error });
61
+ }
62
+
63
+ const MAX_FILE_BYTES = 25 * 1024 * 1024;
64
+
65
+ /** A file name that is safe on every platform, and unique per Slack file. */
66
+ function safeFileName(file) {
67
+ const name = String(file.name ?? file.title ?? "file").replace(/[^\w.-]+/g, "_").slice(-80);
68
+ return `${file.id ?? "file"}-${name || "file"}`;
69
+ }
70
+
71
+ /**
72
+ * Save a file the user shared, and return where it landed. Slack's private URLs want the bot token
73
+ * and the `files:read` scope; without the scope Slack answers with its sign-in page rather than an
74
+ * error, so an HTML answer is reported as the missing scope it really is.
75
+ */
76
+ export async function downloadFile(file, dir) {
77
+ const url = file.url_private_download || file.url_private;
78
+ if (!url) throw new Error("Slack gave no download link");
79
+ if (file.size && file.size > MAX_FILE_BYTES) throw new Error(`too big (${Math.round(file.size / 1048576)}MB)`);
80
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${botToken()}` }, signal: AbortSignal.timeout(60_000) });
81
+ if (!res.ok) throw new Error(`Slack: http_${res.status}`);
82
+ if ((res.headers.get("content-type") ?? "").includes("text/html")) {
83
+ throw new Error("Slack would not hand it over: the app needs the files:read scope (run slack-setup create again)");
84
+ }
85
+ const bytes = Buffer.from(await res.arrayBuffer());
86
+ if (bytes.length > MAX_FILE_BYTES) throw new Error("too big");
87
+ fs.mkdirSync(dir, { recursive: true });
88
+ const target = path.join(dir, safeFileName(file));
89
+ fs.writeFileSync(target, bytes);
90
+ return target;
91
+ }
92
+
93
+ /** Post to a known channel ID (not the configured target), e.g. a reminder beside its question. */
94
+ export async function postToChannel(channel, { text, blocks, thread_ts, color }) {
95
+ const r = await slackCall("chat.postMessage", botToken(), { channel, ...messageBody({ text, blocks, color }), thread_ts, unfurl_links: false, unfurl_media: false });
96
+ if (!r.ok) throw new Error(`Slack: ${r.error}`);
97
+ return { channel: r.channel, ts: r.ts };
98
+ }
99
+
100
+ /** Link to a message, or null if Slack won't say. */
101
+ export async function permalink(channel, ts) {
102
+ const url = `${apiBase()}/chat.getPermalink?channel=${encodeURIComponent(channel)}&message_ts=${encodeURIComponent(ts)}`;
103
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${botToken()}` }, signal: AbortSignal.timeout(15_000) });
104
+ const r = await res.json().catch(() => ({}));
105
+ return r.ok ? r.permalink : null;
106
+ }
107
+
108
+ /** Open a modal in response to a click; `triggerId` is only valid for about 3 seconds. */
109
+ export async function openModal(triggerId, view) {
110
+ const r = await slackCall("views.open", botToken(), { trigger_id: triggerId, view });
111
+ if (!r.ok) throw new Error(`Slack: ${r.error}`);
112
+ }
113
+
114
+ async function slackForm(method, token, params) {
115
+ const res = await fetch(`${apiBase()}/${method}`, {
116
+ method: "POST",
117
+ headers: { Authorization: `Bearer ${token}` },
118
+ body: new URLSearchParams(params),
119
+ signal: AbortSignal.timeout(30_000),
120
+ });
121
+ return res.json().catch(() => ({ ok: false, error: `http_${res.status}` }));
122
+ }
123
+
124
+ /**
125
+ * Upload local files into a channel as one message (Slack's external upload flow:
126
+ * get an upload URL per file, send the bytes, then complete into the channel).
127
+ */
128
+ export async function uploadFiles(channel, files, { comment } = {}) {
129
+ const token = botToken();
130
+ const uploaded = [];
131
+ for (const file of files) {
132
+ const bytes = await fs.promises.readFile(file);
133
+ const title = path.basename(file);
134
+ // Diffs uploaded as a "diff" snippet get Slack's syntax highlighting instead of plain text.
135
+ const snippet = /\.(diff|patch)$/i.test(title) ? { snippet_type: "diff" } : {};
136
+ const slot = await slackForm("files.getUploadURLExternal", token, { filename: title, length: String(bytes.length), ...snippet });
137
+ if (!slot.ok) throw new Error(`Slack upload (${title}): ${slot.error}`);
138
+ const put = await fetch(slot.upload_url, { method: "POST", body: bytes, signal: AbortSignal.timeout(120_000) });
139
+ if (!put.ok) throw new Error(`Slack upload (${title}): HTTP ${put.status}`);
140
+ uploaded.push({ id: slot.file_id, title });
141
+ }
142
+ const done = await slackForm("files.completeUploadExternal", token, {
143
+ files: JSON.stringify(uploaded),
144
+ channel_id: channel,
145
+ ...(comment ? { initial_comment: comment } : {}),
146
+ });
147
+ if (!done.ok) throw new Error(`Slack upload: ${done.error}`);
148
+ return uploaded.map((f) => f.title);
149
+ }
150
+
151
+ /** Add an emoji reaction to a message; an existing identical reaction is not an error. */
152
+ export async function removeReaction(channel, ts, name) {
153
+ const r = await slackCall("reactions.remove", botToken(), { channel, timestamp: ts, name });
154
+ // `no_reaction` just means it was never there, which is not worth reporting.
155
+ if (!r.ok && r.error !== "no_reaction") throw new Error(`Slack: ${r.error}`);
156
+ }
157
+
158
+ export async function addReaction(channel, ts, name) {
159
+ const r = await slackCall("reactions.add", botToken(), { channel, timestamp: ts, name });
160
+ if (!r.ok && r.error !== "already_reacted") throw new Error(`Slack: ${r.error}`);
161
+ }
162
+
163
+ /** The DM channel between the bot and a user. */
164
+ export async function openDm(userId) {
165
+ const r = await slackCall("conversations.open", botToken(), { users: userId });
166
+ if (!r.ok) throw new Error(`Slack: ${r.error}`);
167
+ return r.channel.id;
168
+ }
169
+
170
+ /** Messages in a channel newer than `oldest` (a Slack ts), oldest first. */
171
+ export async function history(channel, oldest) {
172
+ const params = new URLSearchParams({ channel, limit: "100", ...(oldest ? { oldest } : {}) });
173
+ const res = await fetch(`${apiBase()}/conversations.history?${params}`, {
174
+ headers: { Authorization: `Bearer ${botToken()}` },
175
+ signal: AbortSignal.timeout(15_000),
176
+ });
177
+ const r = await res.json().catch(() => ({ ok: false, error: `http_${res.status}` }));
178
+ if (!r.ok) throw Object.assign(new Error(`Slack: ${r.error}`), { code: r.error });
179
+ return (r.messages ?? []).reverse();
180
+ }
181
+
182
+ /** Replies in a thread newer than `oldest`, oldest first, without the parent. */
183
+ export async function replies(channel, threadTs, oldest) {
184
+ const params = new URLSearchParams({ channel, ts: threadTs, limit: "100", ...(oldest ? { oldest } : {}) });
185
+ const res = await fetch(`${apiBase()}/conversations.replies?${params}`, {
186
+ headers: { Authorization: `Bearer ${botToken()}` },
187
+ signal: AbortSignal.timeout(15_000),
188
+ });
189
+ const r = await res.json().catch(() => ({ ok: false, error: `http_${res.status}` }));
190
+ if (!r.ok) throw Object.assign(new Error(`Slack: ${r.error}`), { code: r.error });
191
+ return (r.messages ?? []).filter((m) => m.ts !== threadTs);
192
+ }
193
+
194
+ /**
195
+ * Connect to Socket Mode and hand interactive payloads (clicks, modal submits) to `onInteractive`
196
+ * and Events API events (e.g. DMs to the bot) to `onEvent`. Envelopes are acknowledged immediately.
197
+ * `onClose` fires once when the socket ends for any reason; reconnecting is the caller's decision.
198
+ */
199
+ export async function connectSocket({ onInteractive, onEvent, onClose, log = () => {} }) {
200
+ const token = slackAppToken();
201
+ if (!token) throw new Error("no app-level Slack token (xapp-…) in ROGER_ROGER_SLACK_APP_TOKEN or SLACK_APP_TOKEN");
202
+ if (typeof WebSocket !== "function") throw new Error("Socket Mode needs Node 22+ (global WebSocket)");
203
+
204
+ const open = await slackCall("apps.connections.open", token, {});
205
+ if (!open.ok) throw new Error(`Slack Socket Mode: ${open.error}`);
206
+
207
+ const ws = new WebSocket(open.url);
208
+ let closed = false;
209
+ const finish = (why) => {
210
+ if (closed) return;
211
+ closed = true;
212
+ log(`socket closed: ${why}`);
213
+ onClose?.(why);
214
+ };
215
+
216
+ ws.addEventListener("message", (event) => {
217
+ let msg;
218
+ try {
219
+ msg = JSON.parse(String(event.data));
220
+ } catch {
221
+ return;
222
+ }
223
+ if (msg.envelope_id) ws.send(JSON.stringify({ envelope_id: msg.envelope_id }));
224
+ if (msg.type === "hello") log("socket connected");
225
+ if (msg.type === "disconnect") {
226
+ log(`socket asked to reconnect (${msg.reason})`);
227
+ ws.close();
228
+ }
229
+ if (msg.type === "interactive" && msg.payload) {
230
+ Promise.resolve(onInteractive(msg.payload)).catch((e) => log(`interaction failed: ${e.message}`));
231
+ }
232
+ if (msg.type === "events_api" && msg.payload?.event && onEvent) {
233
+ Promise.resolve(onEvent(msg.payload.event)).catch((e) => log(`event failed: ${e.message}`));
234
+ }
235
+ });
236
+ ws.addEventListener("close", () => finish("close"));
237
+ ws.addEventListener("error", () => finish("error"));
238
+
239
+ return { close: () => ws.close() };
240
+ }
@@ -0,0 +1,205 @@
1
+ // Creating the skill's own Slack app with the Slack CLI, and keeping the tokens it hands over.
2
+ //
3
+ // The Slack CLI only passes an app's tokens to project "hooks", so ~/.roger-roger/slack-app is a
4
+ // minimal Slack CLI project: a copy of the bundled manifest, and a `deploy` hook that calls back
5
+ // into this skill to store the tokens in ~/.roger-roger/slack.json and exit. `slack deploy` creates
6
+ // the app from the manifest on first run, installs it to the workspace, then runs that hook;
7
+ // running it again updates the same app.
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { SKILL_DIR, rogerRogerHome, readSlackCredentials, slackCredentialsPath } from "./lib.mjs";
13
+ import { capture, findCli } from "./slackcli.mjs";
14
+ import { apiBase } from "./slack.mjs";
15
+
16
+ export const MANIFEST_PATH = path.join(SKILL_DIR, "slack", "manifest.json");
17
+ const SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), "roger-roger.mjs");
18
+
19
+ export function projectDir() {
20
+ return path.join(rogerRogerHome(), "slack-app");
21
+ }
22
+
23
+ function saveCredentials(credentials) {
24
+ const file = slackCredentialsPath();
25
+ fs.mkdirSync(path.dirname(file), { recursive: true });
26
+ fs.writeFileSync(file, JSON.stringify(credentials, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
27
+ try {
28
+ fs.chmodSync(file, 0o600); // A no-op on Windows, where the user profile is already private.
29
+ } catch {}
30
+ }
31
+
32
+ /**
33
+ * A command line the Slack CLI can run as a hook. It starts with a bare `node`: the CLI runs hooks
34
+ * through PowerShell on Windows, which rejects a quoted executable path without `&`, while a bare
35
+ * command followed by a quoted script path works there and in sh alike.
36
+ */
37
+ export function hookCommand(name, script = SCRIPT) {
38
+ const quote = (p) => (/[\s'"]/.test(p) ? `"${p}"` : p);
39
+ return `node ${quote(script)} slack-hook ${name}`;
40
+ }
41
+
42
+ export function hooksConfig() {
43
+ return { hooks: { "get-hooks": hookCommand("get-hooks"), deploy: hookCommand("deploy") } };
44
+ }
45
+
46
+ /** Write (or refresh) the project. The app IDs the CLI keeps in .slack/apps.json are left alone. */
47
+ function writeProject() {
48
+ const dir = projectDir();
49
+ fs.mkdirSync(path.join(dir, ".slack"), { recursive: true });
50
+ fs.writeFileSync(path.join(dir, ".slack", "hooks.json"), JSON.stringify(hooksConfig(), null, 2) + "\n");
51
+ // The CLI reads manifest.json from the project root; the skill ships the source of truth.
52
+ fs.copyFileSync(MANIFEST_PATH, path.join(dir, "manifest.json"));
53
+ const configFile = path.join(dir, ".slack", "config.json");
54
+ if (!fs.existsSync(configFile)) fs.writeFileSync(configFile, JSON.stringify({ manifest: { source: "local" } }, null, 2) + "\n");
55
+ return dir;
56
+ }
57
+
58
+ /** The app the CLI created for this project, if any. */
59
+ export function linkedApp() {
60
+ try {
61
+ const apps = JSON.parse(fs.readFileSync(path.join(projectDir(), ".slack", "apps.json"), "utf8"));
62
+ const app = Object.values(apps.apps ?? {})[0];
63
+ return app ? { appId: app.app_id, teamId: app.team_id } : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** Handle a hook invocation from the Slack CLI. Returns the process exit code. */
70
+ export function runHook(name, env = process.env) {
71
+ if (name === "get-hooks") {
72
+ process.stdout.write(JSON.stringify(hooksConfig()));
73
+ return 0;
74
+ }
75
+ if (name !== "deploy") {
76
+ process.stderr.write(`roger-roger: unknown hook "${name}"\n`);
77
+ return 1;
78
+ }
79
+ const botToken = env.SLACK_CLI_XOXB || env.SLACK_BOT_TOKEN || "";
80
+ const appToken = env.SLACK_CLI_XAPP || env.SLACK_APP_TOKEN || "";
81
+ if (!botToken.startsWith("xoxb-")) {
82
+ process.stderr.write("roger-roger: the Slack CLI did not provide a bot token\n");
83
+ return 1;
84
+ }
85
+ saveCredentials({
86
+ source: "slack-cli",
87
+ botToken,
88
+ appToken: appToken.startsWith("xapp-") ? appToken : "",
89
+ savedAt: new Date().toISOString(),
90
+ });
91
+ process.stdout.write(`roger-roger: saved Slack credentials to ${slackCredentialsPath()}\n`);
92
+ return 0;
93
+ }
94
+
95
+ // ---------------------------------------------------------------- driving the Slack CLI
96
+
97
+ /** `slack auth list` text → [{ team, teamId, userId }]. */
98
+ export function parseAuthList(text) {
99
+ const accounts = [];
100
+ const re = /^(.+?) \(Team ID: (\w+)\)\s*\r?\nUser ID: (\w+)/gm;
101
+ for (let m; (m = re.exec(text)); ) accounts.push({ team: m[1].trim(), teamId: m[2], userId: m[3] });
102
+ return accounts;
103
+ }
104
+
105
+ /** The `/slackauthticket …` line from `slack login --no-prompt`. */
106
+ export function parseTicket(text) {
107
+ const m = /\/slackauthticket\s+(\S+)/.exec(text);
108
+ return m ? { command: `/slackauthticket ${m[1]}`, ticket: m[1] } : null;
109
+ }
110
+
111
+ function cleanOutput(text) {
112
+ return text.replace(/<[a-z-]+-hint[^>]*\/>/g, "").trim();
113
+ }
114
+
115
+ /**
116
+ * Run the Slack CLI. Token variables are removed from its environment: the CLI prefers
117
+ * SLACK_BOT_TOKEN / SLACK_APP_TOKEN from the environment over the app's own tokens, which would
118
+ * hand the deploy hook some other app's credentials.
119
+ */
120
+ async function slack(cli, args, opts = {}) {
121
+ const env = { ...process.env };
122
+ for (const k of ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_CLI_XOXB", "SLACK_CLI_XAPP"]) delete env[k];
123
+ const r = await capture(cli.command, [...args, "--skip-update"], { env, timeout: 180_000, ...opts });
124
+ return { ...r, out: cleanOutput(`${r.stdout}\n${r.stderr}`) };
125
+ }
126
+
127
+ async function requireCli() {
128
+ const cli = await findCli();
129
+ if (!cli) throw new Error("the Slack CLI is not installed: run `slack-setup install-cli`");
130
+ return cli;
131
+ }
132
+
133
+ export async function accounts(cli) {
134
+ const r = await slack(cli, ["auth", "list"]);
135
+ return r.code === 0 ? parseAuthList(r.out) : [];
136
+ }
137
+
138
+ async function tokenWorks(method, token) {
139
+ if (!token) return false;
140
+ try {
141
+ const res = await fetch(`${apiBase()}/${method}`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000) });
142
+ return Boolean((await res.json()).ok);
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /** Where onboarding stands, and the next step to take. */
149
+ export async function setupStatus({ checkTokens = true } = {}) {
150
+ const cli = await findCli();
151
+ const logins = cli ? await accounts(cli) : [];
152
+ const stored = readSlackCredentials();
153
+ const credentials = stored
154
+ ? {
155
+ path: slackCredentialsPath(),
156
+ botToken: checkTokens ? await tokenWorks("auth.test", stored.botToken) : Boolean(stored.botToken),
157
+ appToken: checkTokens ? await tokenWorks("apps.connections.open", stored.appToken) : Boolean(stored.appToken),
158
+ }
159
+ : null;
160
+ const next = !cli ? "install-cli" : !logins.length ? "login" : !(credentials?.botToken && credentials?.appToken) ? "create" : "done";
161
+ return { cli, logins, app: linkedApp(), credentials, next };
162
+ }
163
+
164
+ /** Step 1 of login: a ticket the user runs as a slash command in Slack. */
165
+ export async function loginStart() {
166
+ const cli = await requireCli();
167
+ const r = await slack(cli, ["login", "--no-prompt"]);
168
+ const ticket = parseTicket(r.out);
169
+ if (r.code !== 0 || !ticket) throw new Error(`slack login failed: ${r.out}`);
170
+ return ticket;
171
+ }
172
+
173
+ /** Step 2 of login: the challenge code Slack showed the user. */
174
+ export async function loginFinish({ challenge, ticket }) {
175
+ const cli = await requireCli();
176
+ const r = await slack(cli, ["login", "--challenge", challenge, "--ticket", ticket]);
177
+ if (r.code !== 0) throw new Error(`slack login failed: ${r.out}`);
178
+ return accounts(cli);
179
+ }
180
+
181
+ /**
182
+ * Create (or update) the roger-roger app from the manifest in a workspace, install it, and save its
183
+ * tokens. `teamId` may be omitted when the CLI is logged in to exactly one workspace.
184
+ */
185
+ export async function createApp({ teamId } = {}) {
186
+ const cli = await requireCli();
187
+ const logins = await accounts(cli);
188
+ if (!logins.length) throw new Error("the Slack CLI is not logged in: run `slack-setup login`");
189
+ const account = teamId ? logins.find((a) => a.teamId === teamId || a.team === teamId) : logins.length === 1 ? logins[0] : null;
190
+ if (!account) {
191
+ throw new Error(`choose a workspace with --team: ${logins.map((a) => `${a.team} (${a.teamId})`).join(", ")}`);
192
+ }
193
+
194
+ const cwd = writeProject();
195
+ const r = await slack(cli, ["deploy", "--team", account.teamId], { cwd });
196
+ if (r.code !== 0) throw new Error(`slack deploy failed: ${r.out}`);
197
+
198
+ const stored = readSlackCredentials();
199
+ const botOk = await tokenWorks("auth.test", stored?.botToken);
200
+ const appOk = await tokenWorks("apps.connections.open", stored?.appToken);
201
+ if (!botOk || !appOk) {
202
+ throw new Error(`the app was installed but its ${botOk ? "app-level" : "bot"} token does not work; run \`slack-setup create\` again`);
203
+ }
204
+ return { ...linkedApp(), team: account.team, userId: account.userId };
205
+ }