@kolisachint/hoobot 0.0.4 → 0.0.6

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/src/slack.ts ADDED
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Slack surface, over Socket Mode (an outbound WebSocket: no public URL).
3
+ *
4
+ * - A space is a channel (top-level messages) or a thread in it
5
+ * (`<channel>/<thread ts>`); each has its own conversation, as on Discord.
6
+ * - Mention the bot to call it. Slack has no message replies, so replying
7
+ * to the bot isn't a call; mention it in a thread instead.
8
+ * - Context, files, `!` commands, approval buttons and the model menu work
9
+ * as on Discord (src/core.ts, src/session.ts). Text is converted to Slack
10
+ * mrkdwn on the way out (src/mrkdwn.ts).
11
+ */
12
+ import { SocketModeClient } from "@slack/socket-mode";
13
+ import { LogLevel, WebClient } from "@slack/web-api";
14
+ import { readFileSync } from "node:fs";
15
+ import { config, workdirFor } from "./config.ts";
16
+ import { gatherContext, type MessageLike, type SpaceLike } from "./context.ts";
17
+ import type { ChatSpace, Choice, Picked, Posted } from "./chat.ts";
18
+ import { handleCall, helpText } from "./core.ts";
19
+ import { fromMrkdwn, toMrkdwn } from "./mrkdwn.ts";
20
+ import type { AttachmentLike, Fetcher } from "./inbound.ts";
21
+
22
+ const HELP = helpText("Slack", "a menu");
23
+
24
+ /**
25
+ * Slack takes 40k characters but cuts long messages off behind "Show more";
26
+ * and a section block holds 3000. Text is split before conversion, which
27
+ * adds a little (escaping, links).
28
+ */
29
+ const MAX_LENGTH = 2900;
30
+
31
+ /** A Slack message as the Web API returns it (the fields read here). */
32
+ export type SlackMessage = {
33
+ ts: string;
34
+ thread_ts?: string;
35
+ user?: string;
36
+ bot_id?: string;
37
+ username?: string;
38
+ bot_profile?: { name?: string };
39
+ subtype?: string;
40
+ text?: string;
41
+ files?: SlackFile[];
42
+ };
43
+
44
+ export type SlackFile = {
45
+ name?: string;
46
+ title?: string;
47
+ mimetype?: string;
48
+ size?: number;
49
+ url_private_download?: string;
50
+ url_private?: string;
51
+ };
52
+
53
+ /** Message subtypes that are someone talking (others: joins, topic changes, ...). */
54
+ const USER_SUBTYPES = new Set([undefined, "file_share", "thread_broadcast", "bot_message", "me_message"]);
55
+
56
+ export function slackFiles(files: SlackFile[] | undefined): AttachmentLike[] {
57
+ return (files ?? [])
58
+ .filter((f) => f.url_private_download || f.url_private)
59
+ .map((f) => ({
60
+ name: f.name ?? f.title ?? null,
61
+ url: (f.url_private_download ?? f.url_private)!,
62
+ size: f.size ?? 0,
63
+ contentType: f.mimetype ?? null,
64
+ }));
65
+ }
66
+
67
+ /** A space id: the channel, or `<channel>/<thread ts>` for a thread. */
68
+ export function slackSpaceId(channel: string, threadTs?: string | null): string {
69
+ return threadTs ? `${channel}/${threadTs}` : channel;
70
+ }
71
+
72
+ /** Remove the bot's mention; `null` when the text doesn't mention it. */
73
+ export function stripMention(text: string, botUserId: string): string | null {
74
+ const re = new RegExp(`<@${botUserId}(?:\\|[^>]*)?>`, "g");
75
+ if (!re.test(text)) return null;
76
+ return text.replace(re, "").trim();
77
+ }
78
+
79
+ type Pending = { allowed: (userId: string) => boolean; resolve: (p: Picked) => void; choices: Choice[] };
80
+
81
+ export class Slack {
82
+ readonly web: WebClient;
83
+ private socket: SocketModeClient;
84
+ private botUserId = "";
85
+ private names = new Map<string, Promise<string>>();
86
+ private channelNames = new Map<string, Promise<string>>();
87
+ /** Open buttons and menus, by the id in their action ids. */
88
+ private pending = new Map<string, Pending>();
89
+ /** Recent event ids, so a redelivered event isn't answered twice. */
90
+ private seen = new Set<string>();
91
+ private nonce = 0;
92
+
93
+ constructor(botToken: string, appToken: string) {
94
+ this.web = new WebClient(botToken, { logLevel: LogLevel.ERROR });
95
+ this.socket = new SocketModeClient({ appToken, logLevel: config.debug ? LogLevel.DEBUG : LogLevel.WARN });
96
+ }
97
+
98
+ /** Downloads Slack files: they need the bot token. */
99
+ readonly fetcher: Fetcher = (url) =>
100
+ fetch(url, { headers: { Authorization: `Bearer ${config.slackBotToken}` } }).then((res) => {
101
+ // Without `files:read`, Slack answers with its sign-in page instead of the file.
102
+ if (res.ok && res.headers.get("content-type")?.startsWith("text/html") && !/\.html?($|\?)/i.test(url)) {
103
+ throw new Error("Slack sent a web page instead of the file (does the app have the files:read scope?)");
104
+ }
105
+ return res;
106
+ });
107
+
108
+ async start(): Promise<void> {
109
+ const auth = await this.web.auth.test();
110
+ this.botUserId = String(auth.user_id);
111
+ this.socket.on("message", (e: any) => this.onEvent(e));
112
+ this.socket.on("interactive", (e: any) => this.onInteractive(e));
113
+ await this.socket.start();
114
+ console.log(`Slack: logged in as @${auth.user} in ${auth.team}`);
115
+ }
116
+
117
+ async stop(): Promise<void> {
118
+ await this.socket.disconnect().catch(() => {});
119
+ }
120
+
121
+ // ── Events ─────────────────────────────────────────────────────────────────
122
+
123
+ private async onEvent({ ack, event, envelope_id }: { ack: () => Promise<void>; event: any; envelope_id: string }) {
124
+ await ack().catch(() => {});
125
+ if (this.seen.has(envelope_id) || this.seen.has(`${event?.channel}:${event?.ts}`)) return;
126
+ this.remember(envelope_id);
127
+ this.remember(`${event?.channel}:${event?.ts}`);
128
+ try {
129
+ await this.onMessage(event);
130
+ } catch (err) {
131
+ console.error("slack message handler failed", err);
132
+ await this.web.chat
133
+ .postMessage({
134
+ channel: event.channel,
135
+ thread_ts: event.thread_ts,
136
+ text: "Something went wrong on my side. Check the bot's terminal.",
137
+ })
138
+ .catch(() => {});
139
+ }
140
+ }
141
+
142
+ private remember(key: string) {
143
+ this.seen.add(key);
144
+ if (this.seen.size > 2000) this.seen.delete(this.seen.values().next().value!);
145
+ }
146
+
147
+ private async onMessage(event: any) {
148
+ if (!event || event.bot_id || !event.user || event.user === this.botUserId) return;
149
+ if (!USER_SUBTYPES.has(event.subtype)) return;
150
+ // Channels and private channels only, like Discord's text channels.
151
+ if (event.channel_type !== "channel" && event.channel_type !== "group") return;
152
+ const channel: string = event.channel;
153
+ if (config.channelIds.size && !(config.channelIds.has(channel) || config.workspaces.has(channel))) return;
154
+
155
+ const text = stripMention(event.text ?? "", this.botUserId);
156
+ if (text === null) return;
157
+ console.log(`[slack] ${event.user} in ${channel}: ${String(event.text ?? "").slice(0, 80)}`);
158
+
159
+ const threadTs: string | undefined = event.thread_ts && event.thread_ts !== event.ts ? event.thread_ts : undefined;
160
+ const space = this.space(channel, threadTs);
161
+ const history = this.history(channel, threadTs);
162
+ await handleCall(
163
+ {
164
+ space,
165
+ userId: event.user,
166
+ author: await this.name(event.user),
167
+ messageId: event.ts,
168
+ text: fromMrkdwn(text, (id) => this.cachedName(id)),
169
+ files: slackFiles(event.files),
170
+ workdir: workdirFor(channel),
171
+ reply: (t) => space.send(t),
172
+ context: async ({ linked, seen }) =>
173
+ gatherContext({ space: await history, before: event.ts, botId: this.botUserId, linked, seen, surface: "slack" }),
174
+ fetcher: this.fetcher,
175
+ },
176
+ HELP,
177
+ );
178
+ }
179
+
180
+ // ── Spaces ─────────────────────────────────────────────────────────────────
181
+
182
+ /** A Slack channel or thread as a `ChatSpace`. */
183
+ space(channel: string, threadTs?: string): ChatSpace {
184
+ const web = this.web;
185
+ const posted = (ts: string | undefined): Posted => ({
186
+ edit: (text) => (ts ? web.chat.update({ channel, ts, text: toMrkdwn(text), blocks: [] }) : Promise.resolve()),
187
+ delete: () => (ts ? web.chat.delete({ channel, ts }) : Promise.resolve()),
188
+ });
189
+ return {
190
+ id: slackSpaceId(channel, threadTs),
191
+ surface: "slack",
192
+ label: "Slack",
193
+ maxLength: MAX_LENGTH,
194
+ maxChoices: 100,
195
+ async send(text, opts = {}) {
196
+ const files = opts.files ?? [];
197
+ if (files.length) {
198
+ // One message with the text and the files. Upload completes on
199
+ // Slack's side, so there's no message to edit later.
200
+ await web.filesUploadV2({
201
+ channel_id: channel,
202
+ ...(threadTs ? { thread_ts: threadTs } : {}),
203
+ initial_comment: toMrkdwn(text),
204
+ file_uploads: files.map((f) => ({ file: readFileSync(f.attachment), filename: f.name })),
205
+ });
206
+ return posted(undefined);
207
+ }
208
+ const res = await web.chat.postMessage({
209
+ channel,
210
+ ...(threadTs ? { thread_ts: threadTs } : {}),
211
+ text: toMrkdwn(text),
212
+ unfurl_links: false,
213
+ unfurl_media: false,
214
+ });
215
+ return posted(res.ts);
216
+ },
217
+ // Slack has no typing indicator for bots; the status line shows progress.
218
+ sendTyping: async () => {},
219
+ choose: async (text, kind, choices, timeoutMs, placeholder) => {
220
+ const id = String(++this.nonce);
221
+ const res = await web.chat.postMessage({
222
+ channel,
223
+ ...(threadTs ? { thread_ts: threadTs } : {}),
224
+ text: toMrkdwn(text),
225
+ blocks: choiceBlocks(id, toMrkdwn(text), kind, choices, placeholder),
226
+ });
227
+ const msg = posted(res.ts);
228
+ const pick = new Promise<Picked>((resolve, reject) => {
229
+ const timer = setTimeout(() => {
230
+ this.pending.delete(id);
231
+ reject(new Error("timeout"));
232
+ }, timeoutMs);
233
+ this.pending.set(id, {
234
+ choices,
235
+ allowed: (u) => config.allowedUserIds.has(u),
236
+ resolve: (p) => {
237
+ clearTimeout(timer);
238
+ this.pending.delete(id);
239
+ resolve(p);
240
+ },
241
+ });
242
+ });
243
+ return { msg, pick };
244
+ },
245
+ };
246
+ }
247
+
248
+ /** Clicks on buttons and menus. */
249
+ private async onInteractive({ ack, body }: { ack: () => Promise<void>; body: any }) {
250
+ await ack().catch(() => {});
251
+ if (body?.type !== "block_actions") return;
252
+ const action = body.actions?.[0];
253
+ const [prefix, id] = String(action?.action_id ?? "").split(":");
254
+ if (prefix !== "hoo" || !id) return;
255
+ const channel = body.channel?.id ?? body.container?.channel_id;
256
+ const ts = body.message?.ts ?? body.container?.message_ts;
257
+ const pending = this.pending.get(id);
258
+ if (!pending) return; // answered, timed out, or from before a restart
259
+ const userId: string = body.user?.id;
260
+ if (!pending.allowed(userId)) {
261
+ await this.web.chat
262
+ .postEphemeral({
263
+ channel,
264
+ user: userId,
265
+ text: "Only allowed users can answer this.",
266
+ ...(body.message?.thread_ts ? { thread_ts: body.message.thread_ts } : {}),
267
+ })
268
+ .catch(() => {});
269
+ return;
270
+ }
271
+ const value: string | undefined = action.selected_option?.value ?? action.value;
272
+ if (value === undefined) return;
273
+ pending.resolve({
274
+ value,
275
+ user: body.user?.username ?? body.user?.name ?? (await this.name(userId)),
276
+ update: (text) => this.web.chat.update({ channel, ts, text: toMrkdwn(text), blocks: [] }),
277
+ });
278
+ }
279
+
280
+ // ── History (context) ──────────────────────────────────────────────────────
281
+
282
+ /** The space's history in the shape src/context.ts reads. */
283
+ async history(channel: string, threadTs?: string): Promise<SpaceLike> {
284
+ const name = await this.channelName(channel);
285
+ const channelHistory = {
286
+ id: channel,
287
+ messages: {
288
+ fetch: async ({ before, limit }: { before: string; limit: number }) => {
289
+ const res = await this.web.conversations.history({ channel, latest: before, inclusive: false, limit });
290
+ return this.toLike((res.messages ?? []) as SlackMessage[]);
291
+ },
292
+ },
293
+ };
294
+ if (!threadTs) {
295
+ return { ...channelHistory, name, isThread: () => false };
296
+ }
297
+ return {
298
+ id: slackSpaceId(channel, threadTs),
299
+ isThread: () => true,
300
+ threadStart: threadTs,
301
+ parent: { ...channelHistory, name },
302
+ messages: {
303
+ fetch: async ({ before, limit }) => {
304
+ const replies = (await this.replies(channel, threadTs, before)).filter((m) => m.ts !== threadTs);
305
+ return this.toLike(replies.slice(-limit));
306
+ },
307
+ },
308
+ fetchStarterMessage: async () => {
309
+ const res = await this.web.conversations.replies({ channel, ts: threadTs, limit: 1, inclusive: true });
310
+ const first = (res.messages ?? [])[0] as SlackMessage | undefined;
311
+ if (!first || first.ts !== threadTs) return null;
312
+ return (await this.toLike([first])).get(first.ts) ?? null;
313
+ },
314
+ };
315
+ }
316
+
317
+ /** A thread's replies before `before`, oldest first (all pages, up to 1000). */
318
+ private async replies(channel: string, ts: string, before: string): Promise<SlackMessage[]> {
319
+ const out: SlackMessage[] = [];
320
+ let cursor: string | undefined;
321
+ for (let page = 0; page < 5; page++) {
322
+ const res = await this.web.conversations.replies({ channel, ts, latest: before, inclusive: false, limit: 200, cursor });
323
+ out.push(...((res.messages ?? []) as SlackMessage[]));
324
+ cursor = res.response_metadata?.next_cursor || undefined;
325
+ if (!cursor || !res.has_more) break;
326
+ }
327
+ return out;
328
+ }
329
+
330
+ private async toLike(messages: SlackMessage[]): Promise<Map<string, MessageLike>> {
331
+ const out = new Map<string, MessageLike>();
332
+ const ids = [...new Set(messages.map((m) => m.user).filter((u): u is string => !!u))];
333
+ await Promise.all(ids.map((u) => this.name(u)));
334
+ for (const m of messages) out.set(m.ts, toMessageLike(m, (u) => this.cachedName(u)));
335
+ return out;
336
+ }
337
+
338
+ // ── Names ──────────────────────────────────────────────────────────────────
339
+
340
+ private resolved = new Map<string, string>();
341
+
342
+ private cachedName(userId: string): string | undefined {
343
+ return this.resolved.get(userId);
344
+ }
345
+
346
+ /** A user's display name (cached); their id when it can't be looked up. */
347
+ name(userId: string): Promise<string> {
348
+ let p = this.names.get(userId);
349
+ if (!p) {
350
+ p = this.web.users
351
+ .info({ user: userId })
352
+ .then((r: any) => {
353
+ const name = r.user?.profile?.display_name || r.user?.real_name || r.user?.name || userId;
354
+ this.resolved.set(userId, name);
355
+ return name;
356
+ })
357
+ .catch(() => userId);
358
+ this.names.set(userId, p);
359
+ }
360
+ return p;
361
+ }
362
+
363
+ private channelName(channel: string): Promise<string> {
364
+ let p = this.channelNames.get(channel);
365
+ if (!p) {
366
+ p = this.web.conversations
367
+ .info({ channel })
368
+ .then((r: any) => r.channel?.name ?? channel)
369
+ .catch(() => channel);
370
+ this.channelNames.set(channel, p);
371
+ }
372
+ return p;
373
+ }
374
+ }
375
+
376
+ /** A Slack message in the shape src/context.ts reads. */
377
+ export function toMessageLike(m: SlackMessage, name: (userId: string) => string | undefined): MessageLike {
378
+ const bot = !!m.bot_id;
379
+ const username = (m.user && name(m.user)) || m.username || m.bot_profile?.name || m.user || "someone";
380
+ return {
381
+ id: m.ts,
382
+ // 0 = someone talking; 7 (any other) = joins, topic changes, ...
383
+ type: USER_SUBTYPES.has(m.subtype) ? 0 : 7,
384
+ author: { id: m.user ?? m.bot_id ?? "", bot, username },
385
+ member: null,
386
+ cleanContent: fromMrkdwn(m.text ?? "", name),
387
+ attachments: new Map(slackFiles(m.files).map((f, i) => [String(i), { name: f.name }])),
388
+ embeds: [],
389
+ createdTimestamp: Math.round(Number(m.ts) * 1000),
390
+ };
391
+ }
392
+
393
+ /** Block Kit for `choose`: the text, then buttons or a menu. */
394
+ export function choiceBlocks(id: string, text: string, kind: "buttons" | "menu", choices: Choice[], placeholder?: string): any[] {
395
+ const plain = (t: string, max: number) => ({ type: "plain_text", text: t.length > max ? t.slice(0, max - 1) + "…" : t, emoji: true });
396
+ const section = { type: "section", text: { type: "mrkdwn", text: text.slice(0, 3000) } };
397
+ if (kind === "buttons") {
398
+ return [
399
+ section,
400
+ {
401
+ type: "actions",
402
+ elements: choices.map((c, i) => ({
403
+ type: "button",
404
+ action_id: `hoo:${id}:${i}`,
405
+ text: plain(c.label, 75),
406
+ value: c.value.slice(0, 2000),
407
+ ...(c.style ? { style: c.style } : {}),
408
+ })),
409
+ },
410
+ ];
411
+ }
412
+ const options = choices.slice(0, 100).map((c) => ({
413
+ text: plain(c.label, 75),
414
+ value: c.value.slice(0, 150),
415
+ ...(c.description ? { description: plain(c.description, 75) } : {}),
416
+ }));
417
+ const initial = choices.findIndex((c) => c.default);
418
+ return [
419
+ section,
420
+ {
421
+ type: "actions",
422
+ elements: [
423
+ {
424
+ type: "static_select",
425
+ action_id: `hoo:${id}:menu`,
426
+ placeholder: plain(placeholder ?? "Pick one", 150),
427
+ options,
428
+ ...(initial >= 0 && initial < options.length ? { initial_option: options[initial] } : {}),
429
+ },
430
+ ],
431
+ },
432
+ ];
433
+ }
434
+
435
+ /** Connect to Slack and answer calls; returns a stop function. */
436
+ export async function startSlack(): Promise<() => Promise<void>> {
437
+ const slack = new Slack(config.slackBotToken!, config.slackAppToken!);
438
+ await slack.start();
439
+ return () => slack.stop();
440
+ }
package/src/summary.ts CHANGED
@@ -17,11 +17,23 @@ export class TurnSummary {
17
17
  private prs = new Set<string>();
18
18
  private commits = new Set<string>();
19
19
  private files = new Set<string>();
20
+ /** Text of this turn's shell commands and their output (to tell which files it wrote). */
21
+ private shell: string[] = [];
20
22
 
21
23
  constructor(now = Date.now()) {
22
24
  this.startedAt = now;
23
25
  }
24
26
 
27
+ /** Paths of files edited or written this turn, in order. */
28
+ get editedFiles(): string[] {
29
+ return [...this.files];
30
+ }
31
+
32
+ /** What this turn's shell commands said, for finding the files they wrote. */
33
+ get shellText(): string {
34
+ return this.shell.join("\n");
35
+ }
36
+
25
37
  /** An `item/started` item. */
26
38
  started(item: any) {
27
39
  const what = stepLabel(item);
@@ -39,7 +51,10 @@ export class TurnSummary {
39
51
  if (!stepLabel(item)) return;
40
52
  this.steps.add(item.id);
41
53
  if (item.status === "failed" || item.status === "declined" || (item.exitCode ?? 0) !== 0) this.failed++;
42
- if (item.type === "commandExecution") this.scan(item.aggregatedOutput);
54
+ if (item.type === "commandExecution") {
55
+ this.scan(item.aggregatedOutput);
56
+ this.shell.push(String(item.command ?? ""), String(item.aggregatedOutput ?? ""));
57
+ }
43
58
  if (item.type === "fileChange" && item.status !== "declined") {
44
59
  for (const c of item.changes ?? []) if (c?.path) this.files.add(c.path);
45
60
  }