@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 +7 -0
- package/README.md +8 -0
- package/package.json +2 -2
- package/src/config.ts +7 -0
- package/src/core.ts +5 -1
- package/src/mrkdwn.ts +9 -1
- package/src/peers.ts +65 -0
- package/src/slack.ts +72 -5
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.
|
|
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
|
|
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 ||
|
|
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
|
|
192
|
+
author,
|
|
193
|
+
peer,
|
|
167
194
|
messageId: event.ts,
|
|
168
|
-
text:
|
|
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
|
|