@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/.env.example +18 -8
- package/README.md +97 -16
- package/package.json +5 -3
- package/src/attachments.ts +186 -0
- package/src/chat.ts +66 -0
- package/src/config.ts +90 -19
- package/src/context.ts +48 -15
- package/src/core.ts +216 -0
- package/src/discord.ts +187 -0
- package/src/inbound.ts +228 -0
- package/src/index.ts +42 -234
- package/src/mrkdwn.ts +87 -0
- package/src/session.ts +106 -93
- package/src/slack.ts +440 -0
- package/src/summary.ts +16 -1
package/.env.example
CHANGED
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
# Copy to .env and fill in. Never commit .env.
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# Set Discord, Slack, or both. Each runs when its token(s) are set.
|
|
4
|
+
|
|
5
|
+
# Discord: developer portal → your app → Bot → Reset Token
|
|
4
6
|
DISCORD_TOKEN=
|
|
5
7
|
|
|
6
|
-
#
|
|
8
|
+
# Slack (Socket Mode, no public URL needed). See README "Slack setup".
|
|
9
|
+
# Bot token: api.slack.com/apps → your app → OAuth & Permissions (xoxb-...)
|
|
10
|
+
SLACK_BOT_TOKEN=
|
|
11
|
+
# App-level token with connections:write: Basic Information → App-Level Tokens (xapp-...)
|
|
12
|
+
SLACK_APP_TOKEN=
|
|
13
|
+
|
|
14
|
+
# Comma-separated user IDs allowed to use the bot: Discord IDs (digits)
|
|
15
|
+
# and Slack member IDs (U0123ABCD), mixed in one list.
|
|
7
16
|
# Required: the bot refuses to start without it.
|
|
8
17
|
ALLOWED_USER_IDS=758289752645959720
|
|
9
18
|
|
|
10
|
-
# Only answer in the Hoo server.
|
|
19
|
+
# Only answer in the Hoo server (Discord only).
|
|
11
20
|
GUILD_ID=1554149385623838922
|
|
12
21
|
|
|
13
|
-
# Optional: only answer in these channel IDs (comma-separated). Empty = all.
|
|
22
|
+
# Optional: only answer in these channel IDs (comma-separated, Discord or Slack). Empty = all.
|
|
14
23
|
CHANNEL_IDS=
|
|
15
24
|
|
|
16
25
|
# Folder hoocode works in. Default: ./workspace
|
|
17
26
|
HOO_WORKDIR=./workspace
|
|
18
27
|
|
|
19
28
|
# Optional: give channels their own folder, one app-server each.
|
|
20
|
-
# <channel id>=<folder>, comma-separated
|
|
29
|
+
# <channel id>=<folder>, comma-separated (Discord or Slack channel IDs).
|
|
30
|
+
# Other channels use HOO_WORKDIR.
|
|
21
31
|
# These channels are allowed even if CHANNEL_IDS is set. Not with APP_SERVER.
|
|
22
|
-
# WORKSPACES=123456789012345678=~/code/app,
|
|
32
|
+
# WORKSPACES=123456789012345678=~/code/app,C0123ABCD=~/code/site
|
|
23
33
|
WORKSPACES=
|
|
24
34
|
|
|
25
35
|
# Which Codex app-server to talk to. Empty = start `hoocode app-server`
|
|
@@ -37,12 +47,12 @@ HOOCODE_ARGS=
|
|
|
37
47
|
# Optional model for new threads, e.g. anthropic/claude-sonnet-4-5
|
|
38
48
|
MODEL=
|
|
39
49
|
|
|
40
|
-
#
|
|
50
|
+
# Chat channel/thread -> app-server thread links (Discord and Slack share it).
|
|
41
51
|
# Default: ~/.local/share/hoobot/links.json
|
|
42
52
|
LINKS_FILE=
|
|
43
53
|
|
|
44
54
|
# auto = bash/edit/write run without asking (the bot works end to end).
|
|
45
|
-
# ask = they show Allow once / Deny buttons in
|
|
55
|
+
# ask = they show Allow once / Deny buttons in the chat.
|
|
46
56
|
# Only ALLOWED_USER_IDS can talk to the bot either way.
|
|
47
57
|
APPROVALS=auto
|
|
48
58
|
|
package/README.md
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# hoobot
|
|
2
2
|
|
|
3
|
-
Use **hoocode** from Discord.
|
|
3
|
+
Use **hoocode** from Discord and Slack.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
Discord
|
|
6
|
+
Discord ─┐
|
|
7
|
+
├⇄ this bot (Bun) ⇄ Codex app-server protocol ⇄ hoocode app-server
|
|
8
|
+
Slack ───┘
|
|
7
9
|
```
|
|
8
10
|
|
|
9
|
-
Each Discord channel or thread is one shared app-server thread.
|
|
11
|
+
Each Discord or Slack channel or thread is one shared app-server thread.
|
|
12
|
+
Run Discord, Slack or both from one process: each starts when its tokens
|
|
13
|
+
are in `.env`. The bot speaks only standard
|
|
10
14
|
Codex app-server methods, so the server is swappable: `hoocode app-server`
|
|
11
15
|
(default), or the real `codex app-server`, set with `APP_SERVER` in `.env`.
|
|
12
16
|
By default the bot starts `hoocode app-server` itself, in `HOO_WORKDIR`.
|
|
@@ -54,7 +58,57 @@ hoobot
|
|
|
54
58
|
bun start
|
|
55
59
|
```
|
|
56
60
|
|
|
57
|
-
You should see `
|
|
61
|
+
You should see `Discord: logged in as hoo#1234`.
|
|
62
|
+
|
|
63
|
+
## Slack setup
|
|
64
|
+
|
|
65
|
+
Slack uses Socket Mode: the bot opens the connection, so it needs no
|
|
66
|
+
public URL. Discord can stay on or off; set only the Slack tokens for a
|
|
67
|
+
Slack-only bot.
|
|
68
|
+
|
|
69
|
+
1. At [api.slack.com/apps](https://api.slack.com/apps), **Create New App** →
|
|
70
|
+
**From an app manifest**, pick your workspace and paste:
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
display_information:
|
|
74
|
+
name: hoo
|
|
75
|
+
features:
|
|
76
|
+
bot_user:
|
|
77
|
+
display_name: hoo
|
|
78
|
+
always_online: true
|
|
79
|
+
oauth_config:
|
|
80
|
+
scopes:
|
|
81
|
+
bot:
|
|
82
|
+
- app_mentions:read
|
|
83
|
+
- channels:history
|
|
84
|
+
- groups:history
|
|
85
|
+
- channels:read
|
|
86
|
+
- groups:read
|
|
87
|
+
- chat:write
|
|
88
|
+
- files:read
|
|
89
|
+
- files:write
|
|
90
|
+
- users:read
|
|
91
|
+
settings:
|
|
92
|
+
event_subscriptions:
|
|
93
|
+
bot_events:
|
|
94
|
+
- message.channels
|
|
95
|
+
- message.groups
|
|
96
|
+
interactivity:
|
|
97
|
+
is_enabled: true
|
|
98
|
+
socket_mode_enabled: true
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
2. **Basic Information → App-Level Tokens → Generate Token**, add the
|
|
102
|
+
`connections:write` scope. Put the `xapp-…` token in `SLACK_APP_TOKEN=`.
|
|
103
|
+
3. **Install App** to the workspace. Put the **Bot User OAuth Token**
|
|
104
|
+
(`xoxb-…`) in `SLACK_BOT_TOKEN=`.
|
|
105
|
+
4. Add your Slack member ID to `ALLOWED_USER_IDS` (profile → ⋮ → **Copy
|
|
106
|
+
member ID**, e.g. `U0123ABCD`). Discord and Slack IDs go in the same list.
|
|
107
|
+
5. Invite the bot to a channel (`/invite @hoo`), then `bun start`. You
|
|
108
|
+
should see `Slack: logged in as @hoo in <workspace>`.
|
|
109
|
+
|
|
110
|
+
`CHANNEL_IDS` and `WORKSPACES` take Slack channel IDs too (channel details
|
|
111
|
+
→ bottom of the **About** tab, e.g. `C0123ABCD`).
|
|
58
112
|
|
|
59
113
|
## Run in the background (macOS)
|
|
60
114
|
|
|
@@ -78,20 +132,21 @@ The Mac must be awake and logged in for the bot to answer.
|
|
|
78
132
|
Every channel and every thread is a shared space where people and the bot
|
|
79
133
|
work together. Each has its own hoocode conversation, model and settings.
|
|
80
134
|
|
|
81
|
-
- **Call it:** mention the bot (`@hoo fix what we discussed above`) or
|
|
82
|
-
reply to one of its messages. Only `ALLOWED_USER_IDS` can
|
|
83
|
-
It answers right there
|
|
135
|
+
- **Call it:** mention the bot (`@hoo fix what we discussed above`) or,
|
|
136
|
+
on Discord, reply to one of its messages. Only `ALLOWED_USER_IDS` can
|
|
137
|
+
call it. It answers right there (on Discord as a reply to your message).
|
|
84
138
|
- **Context:** when called, it reads everyone's messages in that space
|
|
85
139
|
since it last looked (up to 30, ~12k characters, newest kept), with
|
|
86
140
|
names, as background. The first call reads the last 30. It never sends
|
|
87
141
|
a message twice; messages sent while it was offline go with the next call.
|
|
88
142
|
Replying to someone's message includes that message too.
|
|
89
|
-
- **Threads:** open a
|
|
143
|
+
- **Threads:** open a thread for a side task. It gets its own
|
|
90
144
|
conversation in the same folder; its first call also reads the channel
|
|
91
145
|
messages that led up to it. Results stay in the thread.
|
|
92
146
|
- **Steer:** calling it while it's busy redirects the current run.
|
|
93
|
-
- **Needs** the **Read Message History** permission in those channels
|
|
94
|
-
without it, it works with no
|
|
147
|
+
- **Needs** the **Read Message History** permission in those channels
|
|
148
|
+
(Slack: the bot must be in the channel); without it, it works with no
|
|
149
|
+
context (and logs why).
|
|
95
150
|
- **Model per space:** `!model` lists hoocode's scoped models (your
|
|
96
151
|
`enabledModels`, set with the model picker in the hoocode TUI). The pick
|
|
97
152
|
applies from the next message, the conversation carries on, and it is
|
|
@@ -106,13 +161,29 @@ work together. Each has its own hoocode conversation, model and settings.
|
|
|
106
161
|
line is removed and only the final answer is posted, with a short footer:
|
|
107
162
|
PR link, commit, files edited, steps, time and model.
|
|
108
163
|
`!verbose` shows every step and in-between message instead.
|
|
164
|
+
- **Files:** files it writes in the work folder (`.html`, images, `.pdf`,
|
|
165
|
+
`.md`, `.txt`, `.csv`, `.json`, Office files, `.zip`), creates with a shell
|
|
166
|
+
command, or names in its answer are attached to the answer. Source code isn't; nothing outside the work
|
|
167
|
+
folder is. Up to 10 files and ~9.5 MB per answer. HTML arrives as a
|
|
168
|
+
download; Discord doesn't render it.
|
|
169
|
+
A channel and its threads share a folder. When two of them are working at
|
|
170
|
+
the same time, each answer only gets the files it wrote or names, so a
|
|
171
|
+
file isn't posted in both places. A file nobody names is left out rather
|
|
172
|
+
than guessed.
|
|
173
|
+
- **Sending files:** files on the message that calls it, and on the message
|
|
174
|
+
it replies to (Discord), are saved in the work folder under
|
|
175
|
+
`.discord/<channel>/<message>/` (Slack: `.slack/…`) and the prompt says where they are, so it
|
|
176
|
+
can read, run or edit them. Text files up to 32 KB are pasted into the
|
|
177
|
+
prompt too; images are also shown to the model as images. Up to 25 MB a
|
|
178
|
+
file. `.discord/` and `.slack/` are git-ignored, cleared by `!new`, and files are deleted
|
|
179
|
+
after 7 days. Files in other people's earlier messages are only named.
|
|
109
180
|
|
|
110
181
|
### Commands (in a channel or thread, after the mention)
|
|
111
182
|
|
|
112
183
|
| Command | What it does |
|
|
113
184
|
|---|---|
|
|
114
185
|
| `!stop` | Stop the current run |
|
|
115
|
-
| `!new` | Start a fresh conversation here, for everyone |
|
|
186
|
+
| `!new` | Start a fresh conversation here, for everyone (deletes files sent here) |
|
|
116
187
|
| `!status` | Model, busy or not, folder, thread, server |
|
|
117
188
|
| `!model` | Pick this space's model from a dropdown |
|
|
118
189
|
| `!model <part of name>` | Pick it directly, e.g. `!model kimi` |
|
|
@@ -133,7 +204,8 @@ work together. Each has its own hoocode conversation, model and settings.
|
|
|
133
204
|
How it works: on start the bot writes
|
|
134
205
|
`workspace/.cortexcode/hoo-config.json`, which puts that folder in a
|
|
135
206
|
custom `discord` mode (`auto_allow` follows `APPROVALS`), plus a short
|
|
136
|
-
|
|
207
|
+
chat system prompt in `modes/discord/system.md` (it names Slack when Slack
|
|
208
|
+
runs; the mode keeps the name `discord` so existing folders stay in it). It only rewrites
|
|
137
209
|
these files while they still hold what it generated; edit them and they
|
|
138
210
|
are left alone. With `ask`, the server sends approvals to every client on
|
|
139
211
|
the thread; the first answer wins and the others see it resolved.
|
|
@@ -142,19 +214,28 @@ the thread; the first answer wins and the others see it resolved.
|
|
|
142
214
|
|
|
143
215
|
| Path | Purpose |
|
|
144
216
|
|---|---|
|
|
145
|
-
| `src/index.ts` | Discord
|
|
217
|
+
| `src/index.ts` | Starts Discord and/or Slack, whichever have tokens |
|
|
218
|
+
| `src/core.ts` | Shared by both: app-servers per folder, sessions, allow list, commands, prompt building |
|
|
219
|
+
| `src/chat.ts` | The `ChatSpace` interface a session talks to |
|
|
220
|
+
| `src/discord.ts` | Discord side: mentions and replies → calls; channels and threads → spaces; buttons, menus |
|
|
221
|
+
| `src/slack.ts` | Slack side (Socket Mode): mentions → calls; channels and threads → spaces; Block Kit buttons, menus |
|
|
222
|
+
| `src/mrkdwn.ts` | Markdown → Slack mrkdwn, and Slack text → plain text |
|
|
146
223
|
| `src/context.ts` | What people said since the bot last read a space |
|
|
147
224
|
| `src/session.ts` | One app-server thread per channel or thread; notifications → messages, buttons → approvals |
|
|
148
225
|
| `src/codex-client.ts` | Codex app-server client (`unix://` WebSocket or `stdio:`) |
|
|
149
226
|
| `src/links.ts` | Channel/thread → app-server thread, model, last read message (`LINKS_FILE`) |
|
|
150
|
-
| `src/
|
|
227
|
+
| `src/attachments.ts` | Picks written files to attach to the answer |
|
|
228
|
+
| `src/inbound.ts` | Saves files sent on Discord or Slack into the work folder for the prompt |
|
|
229
|
+
| `src/format.ts` | Splits long replies to fit the chat's message limit |
|
|
151
230
|
| `workspace/` | hoocode's working folder (git-ignored); sessions are saved by hoocode |
|
|
152
231
|
|
|
153
232
|
## Tests
|
|
154
233
|
|
|
155
234
|
```sh
|
|
156
|
-
bun run test #
|
|
157
|
-
bun test/session.e2e.ts # real app-server, fake
|
|
235
|
+
bun run test # unit tests (what CI runs)
|
|
236
|
+
bun test/session.e2e.ts # real app-server, fake chat (uses API credits)
|
|
237
|
+
bun test/attachments.e2e.ts # real app-server writes files; checks they're attached
|
|
238
|
+
bun test/inbound.e2e.ts # files sent to it reach a real app-server
|
|
158
239
|
APP_SERVER="stdio:codex app-server" bun test/session.e2e.ts # same, real Codex
|
|
159
240
|
bun run typecheck
|
|
160
241
|
bun run check # typecheck + unit tests
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolisachint/hoobot",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Use hoocode from Discord: a
|
|
3
|
+
"version": "0.0.6",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -30,13 +30,15 @@
|
|
|
30
30
|
"typescript": "^7.0.2"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
+
"@slack/socket-mode": "^2",
|
|
34
|
+
"@slack/web-api": "^7",
|
|
33
35
|
"discord.js": "^14.27.0"
|
|
34
36
|
},
|
|
35
37
|
"scripts": {
|
|
36
38
|
"start": "bun src/index.ts",
|
|
37
39
|
"dev": "bun --watch src/index.ts",
|
|
38
40
|
"typecheck": "tsc --noEmit",
|
|
39
|
-
"test": "bun test 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",
|
|
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",
|
|
40
42
|
"check": "bun run typecheck && bun run test"
|
|
41
43
|
}
|
|
42
44
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files the model wrote during a turn that are worth sending back to the chat
|
|
3
|
+
* (HTML pages, images, PDFs, ...). Only regular files inside the work folder,
|
|
4
|
+
* of a known type and under Discord's upload limit (the smaller one), are picked.
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, realpathSync, statSync } from "node:fs";
|
|
7
|
+
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
8
|
+
import { INBOX_DIRS } from "./inbound.ts";
|
|
9
|
+
|
|
10
|
+
/** File types sent back as attachments. Source code is left out on purpose. */
|
|
11
|
+
export const ATTACH_EXTENSIONS = new Set([
|
|
12
|
+
".html", ".htm", ".svg",
|
|
13
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp",
|
|
14
|
+
".pdf", ".md", ".txt", ".csv", ".json",
|
|
15
|
+
".docx", ".xlsx", ".pptx", ".zip",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/** Discord's default upload limit is 10 MiB per message; stay under it. */
|
|
19
|
+
export const MAX_TOTAL_BYTES = 9.5 * 1024 * 1024;
|
|
20
|
+
/** Discord allows at most 10 attachments per message. */
|
|
21
|
+
export const MAX_FILES = 10;
|
|
22
|
+
|
|
23
|
+
export type Attachment = { attachment: string; name: string };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* File paths named in the model's answer, e.g. "Saved `out/report.html`".
|
|
27
|
+
* Catches files written by shell commands, which aren't tracked as edits.
|
|
28
|
+
*/
|
|
29
|
+
export function pathsInText(text: string): string[] {
|
|
30
|
+
const out: string[] = [];
|
|
31
|
+
for (const m of text.matchAll(/[\w./~-]+\.[A-Za-z0-9]{2,5}\b/g)) {
|
|
32
|
+
const p = m[0].replace(/^\.\//, "");
|
|
33
|
+
if (p.startsWith("//")) continue; // the tail of a URL
|
|
34
|
+
if (ATTACH_EXTENSIONS.has(extname(p).toLowerCase())) out.push(p);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Folders never scanned: tooling, dependencies, build output. */
|
|
40
|
+
const SKIP_DIRS = new Set(["node_modules", "target", "dist", "build", "__pycache__", "venv"]);
|
|
41
|
+
/** Stop scanning after this many entries, so a huge folder can't stall a reply. */
|
|
42
|
+
const SCAN_LIMIT = 20_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Files of an attachable type under `workdir` modified at or after `sinceMs`,
|
|
46
|
+
* oldest first. Catches files written by shell commands (`python gen.py >
|
|
47
|
+
* out.html`), which the server doesn't report as edits. Skips dot folders
|
|
48
|
+
* (`.git`, `.cortexcode`, ...) and the folders in SKIP_DIRS.
|
|
49
|
+
*/
|
|
50
|
+
export function changedSince(workdir: string, sinceMs: number): string[] {
|
|
51
|
+
const found: { path: string; mtime: number }[] = [];
|
|
52
|
+
let seen = 0;
|
|
53
|
+
const walk = (dir: string) => {
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
if (++seen > SCAN_LIMIT) return;
|
|
62
|
+
const path = join(dir, e.name);
|
|
63
|
+
if (e.isDirectory()) {
|
|
64
|
+
if (!e.name.startsWith(".") && !SKIP_DIRS.has(e.name)) walk(path);
|
|
65
|
+
} else if (e.isFile() && ATTACH_EXTENSIONS.has(extname(e.name).toLowerCase())) {
|
|
66
|
+
try {
|
|
67
|
+
const mtime = statSync(path).mtimeMs;
|
|
68
|
+
if (mtime >= sinceMs) found.push({ path, mtime });
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
walk(workdir);
|
|
74
|
+
return found.sort((a, b) => a.mtime - b.mtime).map((f) => f.path);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Turns running per work folder, so a file changed in a shared folder goes
|
|
79
|
+
* back only to the turn that made it. A channel and its threads share one
|
|
80
|
+
* folder; when their turns overlap, a scan of the folder can't tell whose
|
|
81
|
+
* file is whose.
|
|
82
|
+
*/
|
|
83
|
+
export class TurnLog {
|
|
84
|
+
private turns = new Map<string, Map<string, { start: number; end: number | null }>>();
|
|
85
|
+
|
|
86
|
+
begin(workdir: string, key: string, now = Date.now()) {
|
|
87
|
+
let byKey = this.turns.get(workdir);
|
|
88
|
+
if (!byKey) this.turns.set(workdir, (byKey = new Map()));
|
|
89
|
+
byKey.set(key, { start: now, end: null });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
end(workdir: string, key: string, now = Date.now()) {
|
|
93
|
+
const t = this.turns.get(workdir)?.get(key);
|
|
94
|
+
if (t && t.end === null) t.end = now;
|
|
95
|
+
this.prune(now);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether another turn in `workdir` ran at some point during `key`'s turn. */
|
|
99
|
+
overlapped(workdir: string, key: string, now = Date.now()): boolean {
|
|
100
|
+
const byKey = this.turns.get(workdir);
|
|
101
|
+
const me = byKey?.get(key);
|
|
102
|
+
if (!byKey || !me) return false;
|
|
103
|
+
const myEnd = me.end ?? now;
|
|
104
|
+
for (const [k, t] of byKey) {
|
|
105
|
+
if (k === key) continue;
|
|
106
|
+
if (t.start <= myEnd && (t.end ?? now) >= me.start) return true;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Forget turns that ended over an hour ago; no running turn can overlap them. */
|
|
112
|
+
private prune(now: number) {
|
|
113
|
+
for (const [dir, byKey] of this.turns) {
|
|
114
|
+
for (const [k, t] of byKey) if (t.end !== null && now - t.end > 60 * 60 * 1000) byKey.delete(k);
|
|
115
|
+
if (byKey.size === 0) this.turns.delete(dir);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One log for the whole process: every session in a folder shares it. */
|
|
121
|
+
export const turnLog = new TurnLog();
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Of `changed` (files changed in the folder during a turn), the ones this
|
|
125
|
+
* turn can claim. Alone in the folder: all of them. Overlapping another
|
|
126
|
+
* turn: only those its answer or shell commands name (by path or file name);
|
|
127
|
+
* the rest are ambiguous and go to nobody rather than to everyone.
|
|
128
|
+
*/
|
|
129
|
+
export function claimChanged(changed: string[], workdir: string, alone: boolean, mentions: string): string[] {
|
|
130
|
+
if (alone) return changed;
|
|
131
|
+
return changed.filter((p) => {
|
|
132
|
+
const rel = relative(workdir, p);
|
|
133
|
+
// Whole names only: `b.svg` matches `> b.svg` or `./b.svg`, not `xb.svg.bak` or `sub/b.svg`.
|
|
134
|
+
return [p, rel, basename(p)].some((name) =>
|
|
135
|
+
new RegExp(`(^|[^\\w./-]|(?<![\\w.])\\./)${escapeRe(name)}($|[^\\w./-]|\\.(?!\\w))`).test(mentions),
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function escapeRe(s: string): string {
|
|
141
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The files to attach, in the order given. Relative paths resolve against
|
|
146
|
+
* `workdir`. Skips anything missing, outside `workdir` (after following
|
|
147
|
+
* symlinks), of another type, or past the size/count limits.
|
|
148
|
+
*/
|
|
149
|
+
export function pickAttachments(paths: Iterable<string>, workdir: string): { files: Attachment[]; skipped: string[] } {
|
|
150
|
+
const files: Attachment[] = [];
|
|
151
|
+
const skipped: string[] = [];
|
|
152
|
+
let root: string;
|
|
153
|
+
try {
|
|
154
|
+
root = realpathSync(workdir);
|
|
155
|
+
} catch {
|
|
156
|
+
return { files, skipped };
|
|
157
|
+
}
|
|
158
|
+
const seen = new Set<string>();
|
|
159
|
+
let total = 0;
|
|
160
|
+
for (const p of paths) {
|
|
161
|
+
if (!ATTACH_EXTENSIONS.has(extname(p).toLowerCase())) continue;
|
|
162
|
+
let real: string;
|
|
163
|
+
let size: number;
|
|
164
|
+
try {
|
|
165
|
+
real = realpathSync(resolve(workdir, p));
|
|
166
|
+
const st = statSync(real);
|
|
167
|
+
if (!st.isFile()) continue;
|
|
168
|
+
size = st.size;
|
|
169
|
+
} catch {
|
|
170
|
+
continue; // deleted or never written
|
|
171
|
+
}
|
|
172
|
+
const rel = relative(root, real);
|
|
173
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) continue;
|
|
174
|
+
// Files people sent on a chat: never echoed back.
|
|
175
|
+
if (Object.values(INBOX_DIRS).some((dir) => rel === dir || rel.startsWith(`${dir}/`))) continue;
|
|
176
|
+
if (seen.has(real)) continue;
|
|
177
|
+
seen.add(real);
|
|
178
|
+
if (files.length >= MAX_FILES || total + size > MAX_TOTAL_BYTES) {
|
|
179
|
+
skipped.push(basename(real));
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
total += size;
|
|
183
|
+
files.push({ attachment: real, name: basename(real) });
|
|
184
|
+
}
|
|
185
|
+
return { files, skipped };
|
|
186
|
+
}
|
package/src/chat.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a session needs from a chat surface (Discord, Slack). A space is a
|
|
3
|
+
* channel or a thread; each has its own app-server conversation.
|
|
4
|
+
*
|
|
5
|
+
* Text is Discord-flavoured Markdown (`**bold**`, `-# small`, `[text](<url>)`);
|
|
6
|
+
* a surface that renders something else converts it when sending.
|
|
7
|
+
*/
|
|
8
|
+
import type { Attachment } from "./attachments.ts";
|
|
9
|
+
|
|
10
|
+
export type Surface = "discord" | "slack";
|
|
11
|
+
|
|
12
|
+
/** A message the bot posted. */
|
|
13
|
+
export interface Posted {
|
|
14
|
+
/** Replace the text (and drop any buttons or menu). */
|
|
15
|
+
edit(text: string): Promise<unknown>;
|
|
16
|
+
delete(): Promise<unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Choice = {
|
|
20
|
+
label: string;
|
|
21
|
+
value: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
/** Pre-selected in a menu. */
|
|
24
|
+
default?: boolean;
|
|
25
|
+
style?: "primary" | "danger";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A pick by an allowed user. */
|
|
29
|
+
export interface Picked {
|
|
30
|
+
value: string;
|
|
31
|
+
/** Display name of who picked. */
|
|
32
|
+
user: string;
|
|
33
|
+
/** Replace the message's text and remove the controls. */
|
|
34
|
+
update(text: string): Promise<unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ChatSpace {
|
|
38
|
+
/** Unique within the surface (Discord: channel or thread id; Slack: `channel` or `channel/thread_ts`). */
|
|
39
|
+
id: string;
|
|
40
|
+
surface: Surface;
|
|
41
|
+
/** "Discord", "Slack": for messages people read. */
|
|
42
|
+
label: string;
|
|
43
|
+
/** Longest message text the surface takes (before conversion). */
|
|
44
|
+
maxLength: number;
|
|
45
|
+
/** Most options a menu can show. */
|
|
46
|
+
maxChoices: number;
|
|
47
|
+
/**
|
|
48
|
+
* Post `text`. `replyTo`: the message it answers (a quoted reply where the
|
|
49
|
+
* surface has them). `files`: attached, or posted right after.
|
|
50
|
+
*/
|
|
51
|
+
send(text: string, opts?: { replyTo?: string | null; files?: Attachment[] }): Promise<Posted>;
|
|
52
|
+
/** Show "typing…" where the surface can; else nothing. */
|
|
53
|
+
sendTyping(): Promise<unknown>;
|
|
54
|
+
/**
|
|
55
|
+
* Post `text` with buttons or a menu. `msg` is the posted message;
|
|
56
|
+
* `pick` resolves with the first pick by an allowed user (others are told
|
|
57
|
+
* no) and rejects after `timeoutMs`.
|
|
58
|
+
*/
|
|
59
|
+
choose(
|
|
60
|
+
text: string,
|
|
61
|
+
kind: "buttons" | "menu",
|
|
62
|
+
choices: Choice[],
|
|
63
|
+
timeoutMs: number,
|
|
64
|
+
placeholder?: string,
|
|
65
|
+
): Promise<{ msg: Posted; pick: Promise<Picked> }>;
|
|
66
|
+
}
|