@kolisachint/hoobot 0.0.5 → 0.0.7

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/index.ts CHANGED
@@ -1,272 +1,57 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * hoobot: talk to hoocode from Discord.
3
+ * hoobot: talk to hoocode from Discord and Slack.
4
4
  *
5
5
  * - Every channel and thread is a shared space with its own hoocode conversation.
6
- * - Mention the bot (or reply to it) → it reads what was said since it last
7
- * looked (up to 30 messages) and answers right there.
8
- * - Commands inside a thread: !stop !new !status !model [name] !verbose !help
6
+ * - Mention the bot (or, on Discord, reply to it) → it reads what was said
7
+ * since it last looked (up to 30 messages) and answers right there.
8
+ * - Commands: !stop !new !status !model [name] !verbose !help
9
9
  * - Each channel in WORKSPACES works in its own folder with its own app-server.
10
+ * - Discord runs when DISCORD_TOKEN is set; Slack when SLACK_BOT_TOKEN and
11
+ * SLACK_APP_TOKEN are. Both share the app-servers and the links file.
10
12
  */
11
- import {
12
- ChannelType,
13
- Client,
14
- Events,
15
- GatewayIntentBits,
16
- Partials,
17
- type Message,
18
- type TextChannel,
19
- type ThreadChannel,
20
- } from "discord.js";
21
- import { allWorkdirs, config, prepareWorkspace, workdirFor } from "./config.ts";
22
- import { CodexClient } from "./codex-client.ts";
23
- import { LinkStore } from "./links.ts";
24
- import { ThreadSession } from "./session.ts";
25
- import { authorName, buildPrompt, gatherContext, toContext, type ContextMessage, type SpaceLike } from "./context.ts";
26
- import { clearSpace, formatAttachments, imageInputs, pruneInbox, saveAttachments, type SavedFile, type SkippedFile } from "./inbound.ts";
13
+ import { allWorkdirs, config, prepareWorkspace, surfaces } from "./config.ts";
14
+ import { closeAll } from "./core.ts";
15
+ import { INBOX_DIRS, pruneInbox } from "./inbound.ts";
27
16
 
28
17
  for (const dir of allWorkdirs()) prepareWorkspace(dir);
29
- // Files sent on Discord are kept a week.
18
+ // Files sent on a chat are kept a week.
30
19
  const prune = () => {
31
20
  for (const dir of allWorkdirs()) {
32
21
  try {
33
22
  pruneInbox(dir);
34
23
  } catch (err) {
35
- console.error(`Can't clean ${dir}/.discord: ${err instanceof Error ? err.message : String(err)}`);
24
+ console.error(`Can't clean ${dir}/{${Object.values(INBOX_DIRS).join(",")}}: ${err instanceof Error ? err.message : String(err)}`);
36
25
  }
37
26
  }
38
27
  };
39
28
  prune();
40
29
  setInterval(prune, 60 * 60 * 1000).unref();
41
30
 
42
- const links = new LinkStore(config.linksFile);
43
- const sessions = new Map<string, ThreadSession>();
31
+ console.log(`Working folder: ${config.workdir}`);
32
+ for (const [channel, dir] of config.workspaces) console.log(` channel ${channel} → ${dir}`);
33
+ console.log(`Allowed users: ${[...config.allowedUserIds].join(", ")}`);
44
34
 
45
- /** `unix://PATH`, or `hoocode app-server` over stdio in the work folder. */
46
- function endpoint(): string {
47
- if (config.appServer) return config.appServer;
48
- return `stdio:${config.hoocodeBin} app-server ${config.hoocodeArgs.join(" ")}`;
49
- }
50
-
51
- /** One app-server per folder, started on first use. */
52
- const apps = new Map<string, Promise<CodexClient>>();
53
-
54
- /** The app-server for `workdir`; reconnects after it drops. */
55
- function appServer(workdir: string): Promise<CodexClient> {
56
- let app = apps.get(workdir);
57
- if (app) return app;
58
- app = (async () => {
59
- // A stdio server runs in the work folder; a socket server has its own.
60
- const client = await CodexClient.connect(
61
- endpoint(),
62
- { name: "hoobot", version: "0.1.0" },
63
- { cwd: workdir },
64
- );
65
- console.log(`Connected to app-server ${client.endpoint} in ${workdir}`);
66
- client.on("close", (reason: string) => {
67
- console.error(`app-server for ${workdir} closed: ${reason}`);
68
- apps.delete(workdir);
69
- // Its sessions hold the old client; drop them. Threads resume on the next message.
70
- for (const s of [...sessions.values()]) if (s.workdir === workdir) s.close();
71
- });
72
- return client;
73
- })().catch((err) => {
74
- console.error(`Can't reach app-server for ${workdir}: ${err instanceof Error ? err.message : String(err)}`);
75
- apps.delete(workdir);
76
- throw err;
77
- });
78
- apps.set(workdir, app);
79
- return app;
80
- }
81
- const client = new Client({
82
- intents: [
83
- GatewayIntentBits.Guilds,
84
- GatewayIntentBits.GuildMessages,
85
- GatewayIntentBits.MessageContent,
86
- ],
87
- partials: [Partials.Channel],
88
- });
89
-
90
- const HELP = [
91
- "**hoo: hoocode in Discord**",
92
- "",
93
- "**Call me:** mention me, or reply to one of my messages. I answer right here.",
94
- "Every channel and thread is a shared space: anyone can talk, and when I'm called",
95
- "I read what was said since I last looked (up to 30 messages).",
96
- "Open a thread for a side task; it has its own conversation in the same folder.",
97
- "Attach files (or reply to a message with files): I save them in the work folder and read them.",
98
- "",
99
- "**Commands** (for this channel or thread)",
100
- "- `!stop`: stop the current run",
101
- "- `!new`: start a fresh conversation here (for everyone; deletes files sent here)",
102
- "- `!status`: model, busy or not, folder",
103
- "- `!model`: pick the model here from a list (from the next message; the conversation carries on)",
104
- "- `!model <part of name>`: pick it directly, e.g. `!model kimi`",
105
- "- `!verbose`: show every step here (again to turn off)",
106
- "- `!help`: this message",
107
- "",
108
- "Calling me while I'm busy steers the current run.",
109
- "While I work you see one status line; then the answer with a short",
110
- "summary (PR, commits, files, steps, time, model).",
111
- ...(config.approvals === "ask" ? ["Commands that change files or run shell ask for approval with buttons."] : []),
112
- ].join("\n");
113
-
114
- client.once(Events.ClientReady, (c) => {
115
- console.log(`Logged in as ${c.user.tag}`);
116
- console.log(`Working folder: ${config.workdir}`);
117
- for (const [channel, dir] of config.workspaces) console.log(` channel ${channel} → ${dir}`);
118
- console.log(`Allowed users: ${[...config.allowedUserIds].join(", ")}`);
119
- });
120
-
121
- client.on(Events.MessageCreate, async (message) => {
122
- try {
123
- await onMessage(message);
124
- } catch (err) {
125
- console.error("message handler failed", err);
126
- await message.reply("Something went wrong on my side. Check the bot's terminal.").catch(() => {});
127
- }
35
+ const stops: (() => Promise<void>)[] = [];
36
+ // Loaded only when used, so a Slack-only bot never loads discord.js and the other way round.
37
+ const starters = {
38
+ discord: async () => (await import("./discord.ts")).startDiscord(),
39
+ slack: async () => (await import("./slack.ts")).startSlack(),
40
+ };
41
+ const results = await Promise.allSettled(surfaces().map(async (s) => stops.push(await starters[s]())));
42
+ results.forEach((r, i) => {
43
+ if (r.status === "rejected") console.error(`Can't connect to ${surfaces()[i]}: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
128
44
  });
129
-
130
- async function onMessage(message: Message) {
131
- if (message.author.bot || !client.user) return;
132
- if (config.guildId && message.guildId !== config.guildId) return;
133
-
134
- const channel = message.channel;
135
- if (channel.type !== ChannelType.GuildText && !channel.isThread()) return;
136
- const parentId = channel.isThread() ? channel.parentId : channel.id;
137
- if (config.channelIds.size && (!parentId || !(config.channelIds.has(parentId) || config.workspaces.has(parentId)))) return;
138
-
139
- // Called by a mention (the bot user, or its auto-created role that Discord's
140
- // autocomplete often picks), or by replying to one of the bot's messages.
141
- const botRoleId = message.guild?.members.me?.roles.botRole?.id;
142
- const called =
143
- message.mentions.users.has(client.user.id) ||
144
- (!!botRoleId && message.mentions.roles.has(botRoleId)) ||
145
- message.mentions.repliedUser?.id === client.user.id;
146
- if (!called) return;
147
- console.log(`[msg] ${message.author.username} (${message.author.id}) in ${channel.id}: ${message.content.slice(0, 80)}`);
148
-
149
- // Only allowed users can call the bot (everyone's messages still count as context).
150
- if (!config.allowedUserIds.has(message.author.id)) {
151
- await message.reply("Sorry, you're not on this bot's allow list.");
152
- return;
153
- }
154
-
155
- const text = message.content
156
- .replace(new RegExp(`<@!?${client.user.id}>`, "g"), "")
157
- .replace(botRoleId ? new RegExp(`<@&${botRoleId}>`, "g") : /$^/, "")
158
- .trim();
159
- if (!text && message.attachments.size === 0) {
160
- await message.reply(HELP);
161
- return;
162
- }
163
-
164
- // Commands.
165
- const [cmd, ...rest] = text.split(/\s+/);
166
- const arg = rest.join(" ").trim();
167
- if (cmd?.toLowerCase() === "!help") {
168
- await message.reply(HELP);
169
- return;
170
- }
171
- const space = channel as TextChannel | ThreadChannel;
172
- let session: ThreadSession;
173
- try {
174
- session = await getSession(space, workdirFor(parentId));
175
- } catch (err) {
176
- await message.reply(`**Can't reach hoocode:** ${err instanceof Error ? err.message : String(err)}`);
177
- return;
178
- }
179
- switch (cmd?.toLowerCase()) {
180
- case "!stop":
181
- return session.abort();
182
- case "!new":
183
- clearSpace(session.workdir, space.id);
184
- return session.newSession();
185
- case "!status":
186
- return session.status();
187
- case "!verbose":
188
- return session.toggleVerbose();
189
- case "!model":
190
- return session.chooseModel(arg);
191
- }
192
-
193
- // One call at a time per space, so read positions advance in order.
194
- await inOrder(space.id, async () => {
195
- // What was said here since the conversation last read, plus the message replied to.
196
- const { linked, seen } = await session.readState();
197
- const [context, reply] = await Promise.all([
198
- gatherContext({ space: space as unknown as SpaceLike, before: message.id, botId: client.user!.id, linked, seen }),
199
- fetchReplyTo(message, client.user!.id),
200
- ]);
201
- // Files on the calling message and on the message it replies to are
202
- // saved in the work folder; small text ones are pasted in too.
203
- const saved: SavedFile[] = [];
204
- const skipped: SkippedFile[] = [];
205
- for (const m of [reply?.message, message]) {
206
- if (!m?.attachments.size) continue;
207
- const r = await saveAttachments({
208
- workdir: session.workdir,
209
- spaceId: space.id,
210
- messageId: m.id,
211
- author: authorName(m),
212
- attachments: m.attachments.values(),
213
- });
214
- saved.push(...r.saved);
215
- skipped.push(...r.skipped);
216
- }
217
- const prompt = buildPrompt({
218
- context,
219
- replyTo: reply?.context,
220
- attachments: formatAttachments(saved, skipped),
221
- author: authorName(message),
222
- text: text || "(see the attached files)",
223
- });
224
- if (await session.prompt(prompt, imageInputs(saved), message)) session.markSeen(message.id);
225
- });
226
- }
227
-
228
- /** Per-space promise chains. */
229
- const lanes = new Map<string, Promise<unknown>>();
230
- function inOrder(key: string, fn: () => Promise<unknown>): Promise<unknown> {
231
- const next = (lanes.get(key) ?? Promise.resolve()).then(fn, fn);
232
- const tail = next.catch(() => {});
233
- lanes.set(key, tail);
234
- tail.then(() => lanes.get(key) === tail && lanes.delete(key));
235
- return next;
45
+ if (stops.length === 0) {
46
+ console.error("No chat connected. Check the tokens in .env.");
47
+ process.exit(1);
236
48
  }
237
49
 
238
- /**
239
- * The message `message` replies to, unless it's the bot's own (already in the
240
- * conversation, and its files are already in the work folder).
241
- */
242
- async function fetchReplyTo(message: Message, botId: string): Promise<{ message: Message; context: ContextMessage | null } | null> {
243
- if (!message.reference?.messageId || message.mentions.repliedUser?.id === botId) return null;
244
- try {
245
- const ref = await message.fetchReference();
246
- if (ref.author.id === botId) return null;
247
- return { message: ref, context: toContext(ref, botId) };
248
- } catch {
249
- return null;
250
- }
251
- }
252
-
253
- async function getSession(space: TextChannel | ThreadChannel, workdir: string): Promise<ThreadSession> {
254
- const client = await appServer(workdir);
255
- let s = sessions.get(space.id);
256
- if (!s) {
257
- s = new ThreadSession(space, client, links, (id) => sessions.delete(id), workdir);
258
- sessions.set(space.id, s);
259
- }
260
- return s;
261
- }
262
-
263
- function shutdown() {
50
+ async function shutdown() {
264
51
  console.log("Shutting down…");
265
- for (const s of [...sessions.values()]) s.close();
266
- for (const app of apps.values()) app.then((c) => c.close()).catch(() => {});
267
- client.destroy().finally(() => process.exit(0));
52
+ await closeAll();
53
+ await Promise.allSettled(stops.map((stop) => stop()));
54
+ process.exit(0);
268
55
  }
269
56
  process.on("SIGINT", shutdown);
270
57
  process.on("SIGTERM", shutdown);
271
-
272
- client.login(config.token);
package/src/mrkdwn.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Discord-flavoured Markdown (what the session writes and the model answers
3
+ * in) → Slack mrkdwn.
4
+ *
5
+ * - `**bold**` → `*bold*`, `*italic*` / `_italic_` → `_italic_`, `~~x~~` → `~x~`
6
+ * - `# Heading` → `*Heading*`, `-# small` → plain line
7
+ * - `[text](<url>)` / `[text](url)` → `<url|text>`; bare URLs stay as they are
8
+ * - fenced code keeps its fence, minus the language (Slack shows it as text)
9
+ * - `&`, `<`, `>` are escaped everywhere, as Slack requires
10
+ */
11
+
12
+ const escape = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13
+
14
+ /** Bold/italic markers become these while converting, so `*x*` from `**x**` isn't read as italic. */
15
+ const B = "\u0001";
16
+
17
+ export function toMrkdwn(md: string): string {
18
+ const out: string[] = [];
19
+ let inFence = false;
20
+ for (const line of md.split("\n")) {
21
+ const fence = line.trim().match(/^```/);
22
+ if (fence) {
23
+ // Slack has no language tags: ```ts → ```
24
+ out.push(inFence ? "```" : "```");
25
+ inFence = !inFence;
26
+ continue;
27
+ }
28
+ out.push(inFence ? escape(line) : convertLine(line));
29
+ }
30
+ return out.join("\n");
31
+ }
32
+
33
+ function convertLine(line: string): string {
34
+ const heading = line.match(/^#{1,6}\s+(.*)$/);
35
+ if (heading) return wrapBold(inline(heading[1]!));
36
+ const small = line.match(/^-#\s+(.*)$/);
37
+ if (small) return inline(small[1]!);
38
+ return inline(line);
39
+ }
40
+
41
+ /** A whole line in bold, unless it's bold already. */
42
+ function wrapBold(text: string): string {
43
+ return /^\*[^*]+\*$/.test(text) ? text : `*${text}*`;
44
+ }
45
+
46
+ /** Inline formatting outside code spans; code spans are only escaped. */
47
+ function inline(text: string): string {
48
+ return text
49
+ .split(/(`[^`]*`)/)
50
+ .map((part, i) => (i % 2 === 1 ? escape(part) : prose(part)))
51
+ .join("");
52
+ }
53
+
54
+ function prose(text: string): string {
55
+ const links: string[] = [];
56
+ // Links first (their text and URL must not be touched by escaping or emphasis).
57
+ let s = text.replace(/\[([^\]]+)\]\(<?([^)\s>]+)>?\)/g, (_, label: string, url: string) => {
58
+ links.push(`<${url.replace(/[<>|]/g, encodeURIComponent)}|${escape(label).replace(/\|/g, "¦")}>`);
59
+ return `\u0002${links.length - 1}\u0002`;
60
+ });
61
+ // Slack mentions (`<@U123>`) stay as they are, so a bot can tag another.
62
+ const mentions: string[] = [];
63
+ s = s.replace(/<@([UW][A-Z0-9]+)>/g, (m) => {
64
+ mentions.push(m);
65
+ return `\u0003${mentions.length - 1}\u0003`;
66
+ });
67
+ s = escape(s);
68
+ s = s
69
+ .replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, `${B}$1${B}`)
70
+ .replace(/__(?=\S)(.+?)(?<=\S)__/g, `${B}$1${B}`)
71
+ .replace(/(^|[^\w*])\*(?=\S)([^*]+?)(?<=\S)\*(?![\w*])/g, "$1_$2_")
72
+ .replace(/~~(?=\S)(.+?)(?<=\S)~~/g, "~$1~")
73
+ .replaceAll(B, "*");
74
+ return s
75
+ .replace(/\u0002(\d+)\u0002/g, (_, i: string) => links[Number(i)]!)
76
+ .replace(/\u0003(\d+)\u0003/g, (_, i: string) => mentions[Number(i)]!);
77
+ }
78
+
79
+ /**
80
+ * Slack message text (as typed by people) → plain text for the model:
81
+ * `<@U1>` → `@name`, `<#C1|dev>` → `#dev`, `<url|text>` → `text (url)`,
82
+ * entities unescaped. `name` resolves user ids (unknown ones stay as ids).
83
+ */
84
+ export function fromMrkdwn(text: string, name: (userId: string) => string | undefined = () => undefined): string {
85
+ return text
86
+ .replace(/<@([UW][A-Z0-9]+)(?:\|([^>]+))?>/g, (_, id: string, label?: string) => `@${label ?? name(id) ?? id}`)
87
+ .replace(/<#([CG][A-Z0-9]+)(?:\|([^>]*))?>/g, (_, id: string, label?: string) => `#${label || id}`)
88
+ .replace(/<!(here|channel|everyone)(?:\|[^>]*)?>/g, "@$1")
89
+ .replace(/<!subteam\^[A-Z0-9]+(?:\|([^>]+))?>/g, (_, label?: string) => label ?? "@group")
90
+ .replace(/<(https?:[^>|]+)\|([^>]+)>/g, (_, url: string, label: string) => (label === url ? url : `${label} (${url})`))
91
+ .replace(/<(https?:[^>]+|mailto:[^>]+)>/g, "$1")
92
+ .replace(/&lt;/g, "<")
93
+ .replace(/&gt;/g, ">")
94
+ .replace(/&amp;/g, "&");
95
+ }
package/src/peers.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Peer bots: other bots (e.g. a companion hoobot) that may call this one.
3
+ *
4
+ * Bots answering bots can loop forever, so each thread has a budget: a
5
+ * peer gets `PEER_TURNS` answers per thread. When it's used up, an allowed
6
+ * user is asked (Yes / No buttons) whether to grant `PEER_TURNS` more.
7
+ * A call from a person in the thread starts a fresh budget.
8
+ */
9
+ export class TurnBudget {
10
+ private spaces = new Map<string, { used: number; limit: number }>();
11
+
12
+ constructor(readonly turns: number) {}
13
+
14
+ private get(space: string) {
15
+ let s = this.spaces.get(space);
16
+ if (!s) {
17
+ s = { used: 0, limit: this.turns };
18
+ this.spaces.set(space, s);
19
+ if (this.spaces.size > 1000) this.spaces.delete(this.spaces.keys().next().value!);
20
+ }
21
+ return s;
22
+ }
23
+
24
+ /** Use one peer turn; `false` when the budget is spent (nothing is used). */
25
+ take(space: string): boolean {
26
+ const s = this.get(space);
27
+ if (s.used >= s.limit) return false;
28
+ s.used++;
29
+ return true;
30
+ }
31
+
32
+ /** Allow `turns` more peer turns here. */
33
+ grant(space: string): void {
34
+ this.get(space).limit += this.turns;
35
+ }
36
+
37
+ /** A person called: start over. */
38
+ reset(space: string): void {
39
+ this.spaces.delete(space);
40
+ }
41
+
42
+ used(space: string): number {
43
+ return this.spaces.get(space)?.used ?? 0;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * `@name` for a known peer → a real Slack mention (`<@U123>`), so the model
49
+ * can tag the other bot by name. Runs on mrkdwn (after conversion).
50
+ */
51
+ export function linkMentions(mrkdwn: string, peers: Map<string, string>): string {
52
+ if (!peers.size) return mrkdwn;
53
+ // Code (fenced or inline) is left alone.
54
+ return mrkdwn
55
+ .split(/(```[\s\S]*?```|`[^`\n]*`)/)
56
+ .map((part, i) => {
57
+ if (i % 2 === 1) return part;
58
+ for (const [name, id] of peers) {
59
+ const re = new RegExp(`(^|[^\\w<@])@${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`, "gi");
60
+ part = part.replace(re, `$1<@${id}>`);
61
+ }
62
+ return part;
63
+ })
64
+ .join("");
65
+ }