@kolisachint/hoobot 0.0.6 → 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/.env.example CHANGED
@@ -16,6 +16,13 @@ SLACK_APP_TOKEN=
16
16
  # Required: the bot refuses to start without it.
17
17
  ALLOWED_USER_IDS=758289752645959720
18
18
 
19
+ # Optional (Slack): member IDs of other bots, e.g. a companion hoobot,
20
+ # that may call this bot by mentioning it. Empty = bots are ignored.
21
+ # Each gets PEER_TURNS answers per thread; then an allowed user is asked
22
+ # (Yes / No buttons) for PEER_TURNS more. A person calling resets it.
23
+ PEER_BOT_IDS=
24
+ PEER_TURNS=2
25
+
19
26
  # Only answer in the Hoo server (Discord only).
20
27
  GUILD_ID=1554149385623838922
21
28
 
package/README.md CHANGED
@@ -143,6 +143,13 @@ work together. Each has its own hoocode conversation, model and settings.
143
143
  - **Threads:** open a thread for a side task. It gets its own
144
144
  conversation in the same folder; its first call also reads the channel
145
145
  messages that led up to it. Results stay in the thread.
146
+ - **Two bots talking (Slack):** set `PEER_BOT_IDS` to another bot's
147
+ member ID (e.g. a companion hoobot) and it can call this one by
148
+ mentioning it; `@name` of a peer in an answer becomes a real mention.
149
+ To stop loops, a peer gets `PEER_TURNS` (default 2) answers per thread;
150
+ then an allowed user gets **Yes / No** buttons for 2 more. A person
151
+ calling the bot in that thread starts the count over. Peers can't run
152
+ `!` commands. Other bots are still ignored.
146
153
  - **Steer:** calling it while it's busy redirects the current run.
147
154
  - **Needs** the **Read Message History** permission in those channels
148
155
  (Slack: the bot must be in the channel); without it, it works with no
@@ -219,6 +226,7 @@ the thread; the first answer wins and the others see it resolved.
219
226
  | `src/chat.ts` | The `ChatSpace` interface a session talks to |
220
227
  | `src/discord.ts` | Discord side: mentions and replies → calls; channels and threads → spaces; buttons, menus |
221
228
  | `src/slack.ts` | Slack side (Socket Mode): mentions → calls; channels and threads → spaces; Block Kit buttons, menus |
229
+ | `src/peers.ts` | Peer bots: turns per thread, `@name` → Slack mention |
222
230
  | `src/mrkdwn.ts` | Markdown → Slack mrkdwn, and Slack text → plain text |
223
231
  | `src/context.ts` | What people said since the bot last read a space |
224
232
  | `src/session.ts` | One app-server thread per channel or thread; notifications → messages, buttons → approvals |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolisachint/hoobot",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Use hoocode from Discord and Slack: a chat bot that talks the Codex app-server protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "start": "bun src/index.ts",
39
39
  "dev": "bun --watch src/index.ts",
40
40
  "typecheck": "tsc --noEmit",
41
- "test": "bun test test/mrkdwn.test.ts test/slack.test.ts test/format.test.ts test/codex-client.test.ts test/summary.test.ts test/session.test.ts test/config.test.ts test/context.test.ts test/attachments.test.ts test/inbound.test.ts",
41
+ "test": "bun test test/mrkdwn.test.ts test/slack.test.ts test/format.test.ts test/codex-client.test.ts test/summary.test.ts test/session.test.ts test/config.test.ts test/context.test.ts test/attachments.test.ts test/inbound.test.ts test/peers.test.ts",
42
42
  "check": "bun run typecheck && bun run test"
43
43
  }
44
44
  }
package/src/config.ts CHANGED
@@ -24,6 +24,13 @@ export const config = {
24
24
  * (digits) and Slack member IDs (`U0123ABCD`) in one list.
25
25
  */
26
26
  allowedUserIds: new Set(list("ALLOWED_USER_IDS")),
27
+ /**
28
+ * Slack user IDs of other bots (e.g. a companion hoobot) that may call
29
+ * this one by mentioning it. Empty = bots are ignored.
30
+ */
31
+ peerBotIds: new Set(list("PEER_BOT_IDS")),
32
+ /** Answers a peer bot gets per thread before an allowed user is asked for more. */
33
+ peerTurns: Math.max(1, Number(process.env.PEER_TURNS ?? 2) || 2),
27
34
  /** Optional: restrict to one Discord server / some channels (Discord or Slack channel IDs). */
28
35
  guildId: process.env.GUILD_ID?.trim() || undefined,
29
36
  channelIds: new Set(list("CHANNEL_IDS")),
package/src/core.ts CHANGED
@@ -102,6 +102,8 @@ export interface Call {
102
102
  context(state: { linked: boolean; seen: string | null }): Promise<string>;
103
103
  /** The message this one replies to, unless it's the bot's own. */
104
104
  replyTo?(): Promise<{ context: ContextMessage | null; message: FileMessage } | null>;
105
+ /** Called by a peer bot (`PEER_BOT_IDS`), not a person: no allow list, no commands. */
106
+ peer?: boolean;
105
107
  /** Downloads a file (Slack needs the bot token). */
106
108
  fetcher?: Fetcher;
107
109
  }
@@ -136,11 +138,13 @@ export function helpText(label: string, menu: string): string {
136
138
  export async function handleCall(call: Call, help: string): Promise<void> {
137
139
  const { space } = call;
138
140
  // Only allowed users can call the bot (everyone's messages still count as context).
139
- if (!config.allowedUserIds.has(call.userId)) {
141
+ if (!call.peer && !config.allowedUserIds.has(call.userId)) {
140
142
  await call.reply("Sorry, you're not on this bot's allow list.");
141
143
  return;
142
144
  }
143
145
  const text = call.text.trim();
146
+ // Peers talk; they don't run commands or get help.
147
+ if (call.peer && (!text || text.startsWith("!"))) return;
144
148
  if (!text && call.files.length === 0) {
145
149
  await call.reply(help);
146
150
  return;
package/src/mrkdwn.ts CHANGED
@@ -58,6 +58,12 @@ function prose(text: string): string {
58
58
  links.push(`<${url.replace(/[<>|]/g, encodeURIComponent)}|${escape(label).replace(/\|/g, "¦")}>`);
59
59
  return `\u0002${links.length - 1}\u0002`;
60
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
+ });
61
67
  s = escape(s);
62
68
  s = s
63
69
  .replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, `${B}$1${B}`)
@@ -65,7 +71,9 @@ function prose(text: string): string {
65
71
  .replace(/(^|[^\w*])\*(?=\S)([^*]+?)(?<=\S)\*(?![\w*])/g, "$1_$2_")
66
72
  .replace(/~~(?=\S)(.+?)(?<=\S)~~/g, "~$1~")
67
73
  .replaceAll(B, "*");
68
- return s.replace(/\u0002(\d+)\u0002/g, (_, i: string) => links[Number(i)]!);
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)]!);
69
77
  }
70
78
 
71
79
  /**
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
+ }
package/src/slack.ts CHANGED
@@ -16,7 +16,8 @@ import { config, workdirFor } from "./config.ts";
16
16
  import { gatherContext, type MessageLike, type SpaceLike } from "./context.ts";
17
17
  import type { ChatSpace, Choice, Picked, Posted } from "./chat.ts";
18
18
  import { handleCall, helpText } from "./core.ts";
19
- import { fromMrkdwn, toMrkdwn } from "./mrkdwn.ts";
19
+ import { fromMrkdwn, toMrkdwn as mrkdwn } from "./mrkdwn.ts";
20
+ import { linkMentions, TurnBudget } from "./peers.ts";
20
21
  import type { AttachmentLike, Fetcher } from "./inbound.ts";
21
22
 
22
23
  const HELP = helpText("Slack", "a menu");
@@ -89,6 +90,12 @@ export class Slack {
89
90
  /** Recent event ids, so a redelivered event isn't answered twice. */
90
91
  private seen = new Set<string>();
91
92
  private nonce = 0;
93
+ /** Peer bots by lowercase name → user id, so `@name` in an answer becomes a mention. */
94
+ private peers = new Map<string, string>();
95
+ /** Answers to peer bots per thread (see src/peers.ts). */
96
+ private budget = new TurnBudget(config.peerTurns);
97
+ /** Spaces where "allow more turns?" is already asked. */
98
+ private asking = new Set<string>();
92
99
 
93
100
  constructor(botToken: string, appToken: string) {
94
101
  this.web = new WebClient(botToken, { logLevel: LogLevel.ERROR });
@@ -108,10 +115,19 @@ export class Slack {
108
115
  async start(): Promise<void> {
109
116
  const auth = await this.web.auth.test();
110
117
  this.botUserId = String(auth.user_id);
118
+ for (const id of config.peerBotIds) {
119
+ if (id === this.botUserId) continue;
120
+ const info: any = await this.web.users.info({ user: id }).catch(() => null);
121
+ for (const n of [info?.user?.name, info?.user?.profile?.display_name, info?.user?.real_name]) {
122
+ if (n && /^[\w.-]+$/.test(n)) this.peers.set(String(n).toLowerCase(), id);
123
+ }
124
+ if (!info) console.error(`PEER_BOT_IDS: can't look up ${id}`);
125
+ }
111
126
  this.socket.on("message", (e: any) => this.onEvent(e));
112
127
  this.socket.on("interactive", (e: any) => this.onInteractive(e));
113
128
  await this.socket.start();
114
129
  console.log(`Slack: logged in as @${auth.user} in ${auth.team}`);
130
+ if (this.peers.size) console.log(`Slack: peer bots ${[...new Set(this.peers.keys())].map((n) => "@" + n).join(", ")} (${config.peerTurns} turns per thread)`);
115
131
  }
116
132
 
117
133
  async stop(): Promise<void> {
@@ -145,7 +161,10 @@ export class Slack {
145
161
  }
146
162
 
147
163
  private async onMessage(event: any) {
148
- if (!event || event.bot_id || !event.user || event.user === this.botUserId) return;
164
+ if (!event || !event.user || event.user === this.botUserId) return;
165
+ // Bots are ignored, except peers (PEER_BOT_IDS).
166
+ const peer = !!event.bot_id || config.peerBotIds.has(event.user);
167
+ if (peer && !config.peerBotIds.has(event.user)) return;
149
168
  if (!USER_SUBTYPES.has(event.subtype)) return;
150
169
  // Channels and private channels only, like Discord's text channels.
151
170
  if (event.channel_type !== "channel" && event.channel_type !== "group") return;
@@ -158,14 +177,22 @@ export class Slack {
158
177
 
159
178
  const threadTs: string | undefined = event.thread_ts && event.thread_ts !== event.ts ? event.thread_ts : undefined;
160
179
  const space = this.space(channel, threadTs);
180
+ if (!peer) this.budget.reset(space.id);
181
+ else if (!(await this.peerTurn(space))) return;
161
182
  const history = this.history(channel, threadTs);
183
+ const author = await this.name(event.user);
184
+ let said = fromMrkdwn(text, (id) => this.cachedName(id));
185
+ if (peer) {
186
+ said += `\n\n(${author} is a bot. To answer it, mention @${author}; leave the mention out to end the conversation.)`;
187
+ }
162
188
  await handleCall(
163
189
  {
164
190
  space,
165
191
  userId: event.user,
166
- author: await this.name(event.user),
192
+ author,
193
+ peer,
167
194
  messageId: event.ts,
168
- text: fromMrkdwn(text, (id) => this.cachedName(id)),
195
+ text: said,
169
196
  files: slackFiles(event.files),
170
197
  workdir: workdirFor(channel),
171
198
  reply: (t) => space.send(t),
@@ -177,11 +204,51 @@ export class Slack {
177
204
  );
178
205
  }
179
206
 
207
+ /**
208
+ * Use one peer turn here. When they're spent, ask an allowed user
209
+ * (Yes / No) for more; `false` = don't answer.
210
+ */
211
+ private async peerTurn(space: ChatSpace): Promise<boolean> {
212
+ if (this.budget.take(space.id)) return true;
213
+ if (this.asking.has(space.id)) return false;
214
+ this.asking.add(space.id);
215
+ try {
216
+ const n = config.peerTurns;
217
+ const { msg, pick } = await space.choose(
218
+ `I've answered the other bot ${this.budget.used(space.id)} times in this thread. Allow ${n} more turns?`,
219
+ "buttons",
220
+ [
221
+ { label: `Yes, ${n} more`, value: "yes", style: "primary" },
222
+ { label: "No, stop", value: "no", style: "danger" },
223
+ ],
224
+ config.approvalTimeoutMs,
225
+ );
226
+ const picked = await pick.catch(() => null);
227
+ if (!picked) {
228
+ await msg.edit("No answer, so the bots stop talking here.").catch(() => {});
229
+ return false;
230
+ }
231
+ if (picked.value !== "yes") {
232
+ await picked.update(`${picked.user} said stop. The bots stop talking here.`).catch(() => {});
233
+ return false;
234
+ }
235
+ await picked.update(`${picked.user} allowed ${n} more turns.`).catch(() => {});
236
+ this.budget.grant(space.id);
237
+ return this.budget.take(space.id);
238
+ } finally {
239
+ this.asking.delete(space.id);
240
+ }
241
+ }
242
+
243
+ /** Slack mrkdwn, with `@peer` turned into a real mention. */
244
+ private toMrkdwn = (md: string) => linkMentions(mrkdwn(md), this.peers);
245
+
180
246
  // ── Spaces ─────────────────────────────────────────────────────────────────
181
247
 
182
248
  /** A Slack channel or thread as a `ChatSpace`. */
183
249
  space(channel: string, threadTs?: string): ChatSpace {
184
250
  const web = this.web;
251
+ const toMrkdwn = this.toMrkdwn;
185
252
  const posted = (ts: string | undefined): Posted => ({
186
253
  edit: (text) => (ts ? web.chat.update({ channel, ts, text: toMrkdwn(text), blocks: [] }) : Promise.resolve()),
187
254
  delete: () => (ts ? web.chat.delete({ channel, ts }) : Promise.resolve()),
@@ -273,7 +340,7 @@ export class Slack {
273
340
  pending.resolve({
274
341
  value,
275
342
  user: body.user?.username ?? body.user?.name ?? (await this.name(userId)),
276
- update: (text) => this.web.chat.update({ channel, ts, text: toMrkdwn(text), blocks: [] }),
343
+ update: (text) => this.web.chat.update({ channel, ts, text: this.toMrkdwn(text), blocks: [] }),
277
344
  });
278
345
  }
279
346