@gitterm/discord-bot 0.0.0-stage → 0.1.0
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/README.md +261 -2
- package/dist/adapter.d.ts +18 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +364 -0
- package/dist/components.d.ts +27 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +336 -0
- package/package.json +50 -4
package/README.md
CHANGED
|
@@ -1,3 +1,262 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @gitterm/discord-bot
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Discord bot that puts a coding agent on your repository. Mention it in a channel and it opens a
|
|
4
|
+
thread, an OpenCode agent works on the request in a [GitTerm](https://gitterm.dev) sandbox, asks
|
|
5
|
+
back with buttons when it needs a decision, and replies in the thread.
|
|
6
|
+
|
|
7
|
+
You run one small process anywhere (your laptop, a VPS, Railway, a container). It connects to
|
|
8
|
+
Discord's gateway, so it needs no public URL.
|
|
9
|
+
|
|
10
|
+
## Set up in five minutes
|
|
11
|
+
|
|
12
|
+
1. **Create the bot under Bots in the GitTerm dashboard** and choose Discord. Pick a model, the
|
|
13
|
+
repository, GitHub access, and optionally tools, where it answers, and who can use it. GitTerm
|
|
14
|
+
saves these settings (never your tokens) and gives you the bot's API token, shown once, in a
|
|
15
|
+
ready `.env` that holds only secrets. Change its settings on the bot's page any time; the bot
|
|
16
|
+
loads them each time it starts.
|
|
17
|
+
|
|
18
|
+
2. **Create the Discord bot.** In the
|
|
19
|
+
[Discord developer portal](https://discord.com/developers/applications), create an
|
|
20
|
+
application, open **Bot**, copy the token into `DISCORD_BOT_TOKEN`, and turn on **Message
|
|
21
|
+
Content Intent** (the bot reads thread messages for context and typed answers).
|
|
22
|
+
|
|
23
|
+
3. **Start it** next to the `.env`, from the terminal or from your own code:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npx @gitterm/discord-bot
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { createDiscordBot } from "@gitterm/discord-bot";
|
|
31
|
+
|
|
32
|
+
await createDiscordBot({
|
|
33
|
+
repo: "https://github.com/acme/app",
|
|
34
|
+
model: "anthropic/claude-sonnet-5-5",
|
|
35
|
+
}).start(); // tokens from the environment, or token / gitterm: { token }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The bot checks everything before it connects (token, model credential, GitHub access, tools,
|
|
39
|
+
compute) and prints what to fix if something is missing.
|
|
40
|
+
|
|
41
|
+
4. **Invite it.** Open the invite link the bot prints on startup and pick your server. It asks
|
|
42
|
+
only for what it uses: view channels, send messages, create and post in threads, read history,
|
|
43
|
+
add reactions, and embed links.
|
|
44
|
+
|
|
45
|
+
5. **Mention it** in a channel: `@Acme Agent why does the login page flash on load?`
|
|
46
|
+
|
|
47
|
+
## How it works
|
|
48
|
+
|
|
49
|
+
- **One sandbox per repository.** The first message creates a GitTerm workspace for the
|
|
50
|
+
repository. It pauses when idle and wakes up with the next message, so the checkout, installed
|
|
51
|
+
dependencies, and branches stay where the agent left them, and you pay only while it works.
|
|
52
|
+
- **One session per thread.** A mention in a channel opens a thread for the conversation; mention
|
|
53
|
+
the bot (or reply to it) in the thread to continue with full context. Anyone in the thread can
|
|
54
|
+
follow up, and messages posted between mentions are passed along as context. Threads work in
|
|
55
|
+
parallel; messages within one thread wait their turn. Mentioning the bot inside a thread you
|
|
56
|
+
started yourself works the same way, with the thread so far as context.
|
|
57
|
+
- **Pick a repository per thread.** With `repos`, people name one in a thread's first message
|
|
58
|
+
(`@Acme Agent in acme/api, why is login slow?`); a channel without a repository asks which one.
|
|
59
|
+
The thread stays on that repository.
|
|
60
|
+
- **Credited pull requests.** The agent opens pull requests that start with "Requested by <name> in Discord", without linking the conversation (the repository may be public). The request gets 👀 when picked up, then ✅ or ❌.
|
|
61
|
+
- **It asks back.** Agent questions appear as a card with a button per option (a menu for
|
|
62
|
+
multiple choice or long lists, a dialog for a written answer). Tool approvals show _Allow
|
|
63
|
+
once_, _Always allow_, and _Deny_. The person who asked can also reply in the thread with an
|
|
64
|
+
option number, `yes`, or `no`.
|
|
65
|
+
- **Images reach the agent.** PNG, JPEG, GIF, and WebP files up to 5 MB, posted with the mention
|
|
66
|
+
or earlier in the thread, are attached to the run. Other files are only named.
|
|
67
|
+
- **Restarts are safe.** Runs live in GitTerm, not in the bot. A restarted bot reattaches to
|
|
68
|
+
running work and posts the result. The state file only holds thread → run ids, so losing it
|
|
69
|
+
costs thread continuity, never sandboxes (they are found by their tags).
|
|
70
|
+
- **Open the sandbox yourself.** Each repository's sandbox is the GitTerm workspace named
|
|
71
|
+
`discord-bot-<repository>`. Its dashboard page has the server URL and password, so you can
|
|
72
|
+
attach the OpenCode TUI or desktop app and see every thread's session. Prompts you send there
|
|
73
|
+
are not posted back to Discord.
|
|
74
|
+
|
|
75
|
+
### Commands
|
|
76
|
+
|
|
77
|
+
Mention the bot with one word:
|
|
78
|
+
|
|
79
|
+
| Command | What it does |
|
|
80
|
+
| -------- | -------------------------------------------------------------------------- |
|
|
81
|
+
| `stop` | Cancel the work in this thread. |
|
|
82
|
+
| `status` | Show the repository, sandbox state, model, tools, and who may use the bot. |
|
|
83
|
+
| `reset` | Terminate the sandbox; the next message creates a fresh one. |
|
|
84
|
+
| `help` | Explain how to use the bot. |
|
|
85
|
+
|
|
86
|
+
Use `reset` after changing the bot's instructions, connections, or workspace settings: they apply
|
|
87
|
+
when a sandbox is created.
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
Settings saved for the bot under Bots in the dashboard load when it starts, found by its API
|
|
92
|
+
token. Every setting below overrides them, from code or from the terminal: in code, pass it to
|
|
93
|
+
`createDiscordBot(await withSavedConfig({ ... }))`; on the command line, set the environment
|
|
94
|
+
variable (a `.env` file in the working directory is loaded) or the flag. Without saved settings,
|
|
95
|
+
these are the whole configuration.
|
|
96
|
+
|
|
97
|
+
| Option | Environment variable / flag | Meaning |
|
|
98
|
+
| --------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
99
|
+
| `token` | `DISCORD_BOT_TOKEN` | Bot token. |
|
|
100
|
+
| `client` | — | A discord.js client you already run; see [below](#add-it-to-a-bot-you-already-run). |
|
|
101
|
+
| `gitterm` | `GITTERM_API_TOKEN`, `GITTERM_SERVER_URL` | `{ token, serverUrl }` or a client. Falls back to the `gitterm login` config. |
|
|
102
|
+
| `repo` | `GITTERM_BOT_REPO`, `--repo` | Repository for every channel; `url#branch` picks a branch. |
|
|
103
|
+
| `channels` | `GITTERM_BOT_CHANNELS` (`1234=https://github.com/acme/api,…`) | Per-channel repositories by channel id. Without `repo`, the bot answers only in these channels. |
|
|
104
|
+
| `repos` | `GITTERM_BOT_REPOS` | More repositories people can name in a thread's first message (`in acme/api`). |
|
|
105
|
+
| `connections` | `GITTERM_BOT_CONNECTIONS` (`Linear,Sentry`) | Tools besides GitHub, by name as shown under Integrations, integration key (`executor`), or id. GitHub access for the repository is attached automatically. |
|
|
106
|
+
| `model` | `GITTERM_BOT_MODEL`, `--model`, `GITTERM_BOT_MODEL_CREDENTIAL`, `GITTERM_BOT_MODEL_API_KEY` | `provider/model` for every run; only that provider's credential reaches the sandbox. See [Models](#models). |
|
|
107
|
+
| `instructions` | `GITTERM_BOT_INSTRUCTIONS`, `GITTERM_BOT_INSTRUCTIONS_FILE`, `--instructions-file` | What the agent should know and how it should behave; see [Instructions](#instructions). |
|
|
108
|
+
| `env` | `GITTERM_BOT_SANDBOX_ENV_FILE` (a dotenv file) | Environment variables for new sandboxes, e.g. test credentials. |
|
|
109
|
+
| `setup` | `GITTERM_BOT_SETUP` | Commands run in the checkout before the agent starts, e.g. `pnpm install`. |
|
|
110
|
+
| `allowedUsers` | `GITTERM_BOT_ALLOWED_USERS` | Only these user ids may use the bot. Default: everyone in the channel. |
|
|
111
|
+
| `workspace` | `GITTERM_BOT_PROVIDER` (provider only) | Any `workspaces.create()` setting for new sandboxes: provider, image, setup, OpenCode config. |
|
|
112
|
+
| `workspace.repositoryCredentials` | `GITTERM_BOT_GITHUB_TOKEN` | Your own GitHub token instead of a GitTerm GitHub connection. |
|
|
113
|
+
| `stateFile` | `GITTERM_BOT_STATE_FILE` | Where thread sessions are kept. Default `.gitterm-bot/discord.json`. |
|
|
114
|
+
| `runTimeoutMs` | `GITTERM_BOT_RUN_TIMEOUT_MINUTES` | Give up on a request after this long, including time waiting for answers. Default 60 min. |
|
|
115
|
+
| `inputTimeoutMs` | — | How long a question or approval waits for an answer. Default 30 min. |
|
|
116
|
+
| `logger` | — | Where the bot logs. Default `console`. |
|
|
117
|
+
|
|
118
|
+
Copy channel ids with Developer Mode on. GitHub access is automatic: GitTerm attaches the GitHub
|
|
119
|
+
connection that covers the repository's owner, or the deployment's shared one. Tools are attached
|
|
120
|
+
only when you name them, because the bot acts with your accounts on behalf of everyone in the
|
|
121
|
+
channel. `workspace` takes any [`@gitterm/sdk`](https://www.npmjs.com/package/@gitterm/sdk)
|
|
122
|
+
`workspaces.create()` option except the repository, name, agent, connections, and tags, which the
|
|
123
|
+
bot owns.
|
|
124
|
+
|
|
125
|
+
### Instructions
|
|
126
|
+
|
|
127
|
+
Tell the agent about your team and how to behave. The text is added after the bot's own rules and
|
|
128
|
+
loaded into every session:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { readFile } from "node:fs/promises";
|
|
132
|
+
|
|
133
|
+
createDiscordBot({
|
|
134
|
+
repo: "https://github.com/acme/app",
|
|
135
|
+
instructions: await readFile("bot-instructions.md", "utf8"),
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
npx @gitterm/discord-bot --repo https://github.com/acme/app --instructions-file bot-instructions.md
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Instructions are written into a sandbox when it is created; after changing them, mention the bot
|
|
144
|
+
with `reset`. Project knowledge that belongs with the code (how to run tests, where things live)
|
|
145
|
+
is better in an `AGENTS.md` in the repository, which OpenCode reads on its own.
|
|
146
|
+
|
|
147
|
+
### Models
|
|
148
|
+
|
|
149
|
+
Pick the model and the bot hands the sandbox that one provider's credential, nothing else. A
|
|
150
|
+
sandbox anyone in the channel can drive should not hold every model account on your dashboard.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
model: "anthropic/claude-sonnet-5-5" // the provider's default saved credential
|
|
154
|
+
model: { id: "anthropic/claude-sonnet-5-5", credential: "team" } // a saved credential by label
|
|
155
|
+
model: { id: "anthropic/claude-sonnet-5-5", apiKey: process.env.KEY } // a key for this bot only, never saved
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
On the command line: `GITTERM_BOT_MODEL`, plus `GITTERM_BOT_MODEL_CREDENTIAL` or
|
|
159
|
+
`GITTERM_BOT_MODEL_API_KEY`. Models that need no key (such as OpenCode's free models) get no
|
|
160
|
+
credential. Without a model, the sandbox gets every saved dashboard credential and the dashboard's
|
|
161
|
+
default model. Credentials are set when a sandbox is created; after switching to another
|
|
162
|
+
provider, mention the bot with `reset`.
|
|
163
|
+
|
|
164
|
+
## Running from code
|
|
165
|
+
|
|
166
|
+
`createDiscordBot()` returns `{ start(), stop() }`. `start()` resolves once the bot is connected;
|
|
167
|
+
`stop()` disconnects it. Runs keep going in GitTerm while the bot is down, and the next
|
|
168
|
+
`start()` picks them up.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { createDiscordBot } from "@gitterm/discord-bot";
|
|
172
|
+
|
|
173
|
+
const bot = createDiscordBot({
|
|
174
|
+
repo: "https://github.com/acme/app#main",
|
|
175
|
+
channels: { "1234567890": "https://github.com/acme/api" },
|
|
176
|
+
instructions: "Run `pnpm test` before opening a pull request.",
|
|
177
|
+
workspace: {
|
|
178
|
+
provider: { type: "railway" },
|
|
179
|
+
setup: { beforeAgent: ["pnpm install"] },
|
|
180
|
+
},
|
|
181
|
+
logger: myLogger, // anything with info, warn, and error
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await bot.start();
|
|
185
|
+
process.once("SIGTERM", () => void bot.stop());
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Add it to a bot you already run
|
|
189
|
+
|
|
190
|
+
Pass your discord.js client and the agent answers its mentions alongside your own handlers. The
|
|
191
|
+
client needs the `Guilds`, `GuildMessages`, and `MessageContent` intents (the bot tells you which
|
|
192
|
+
are missing). You log it in and destroy it yourself; `start()` resolves once it is ready, so log
|
|
193
|
+
in without waiting on it. Make sure none of your own handlers also answer mentions of the bot.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { Client, GatewayIntentBits } from "discord.js";
|
|
197
|
+
import { createDiscordBot } from "@gitterm/discord-bot";
|
|
198
|
+
|
|
199
|
+
const client = new Client({
|
|
200
|
+
intents: [
|
|
201
|
+
GatewayIntentBits.Guilds,
|
|
202
|
+
GatewayIntentBits.GuildMessages,
|
|
203
|
+
GatewayIntentBits.MessageContent,
|
|
204
|
+
],
|
|
205
|
+
});
|
|
206
|
+
client.on("interactionCreate", handleMySlashCommands);
|
|
207
|
+
|
|
208
|
+
const agent = createDiscordBot({ client, repo: "https://github.com/acme/app" });
|
|
209
|
+
await Promise.all([agent.start(), client.login(process.env.DISCORD_BOT_TOKEN)]);
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Slack and Discord in one process
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
import { createDiscordBot } from "@gitterm/discord-bot";
|
|
216
|
+
import { createSlackBot } from "@gitterm/slack-bot";
|
|
217
|
+
|
|
218
|
+
const settings = {
|
|
219
|
+
repo: "https://github.com/acme/app",
|
|
220
|
+
gitterm: { token: process.env.GITTERM_API_TOKEN },
|
|
221
|
+
};
|
|
222
|
+
await Promise.all([createSlackBot(settings).start(), createDiscordBot(settings).start()]);
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Each platform gets its own sandbox and its own state file.
|
|
226
|
+
|
|
227
|
+
## Hosting
|
|
228
|
+
|
|
229
|
+
The image runs either bot and picks the platform from the token in the `.env`. In the folder
|
|
230
|
+
with the `.env`:
|
|
231
|
+
|
|
232
|
+
```sh
|
|
233
|
+
docker run -d --name gitterm-discord-bot --restart unless-stopped \
|
|
234
|
+
-v "$PWD/.env:/data/.env:ro" -v gitterm-discord-bot:/data \
|
|
235
|
+
ghcr.io/opeoginni/gitterm-bot
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The `.env` is mounted rather than passed with `--env-file`, which doesn't read dotenv quoting.
|
|
239
|
+
Thread sessions are kept in the `/data` volume. Without Docker, any machine with Node 22.12+
|
|
240
|
+
works: run `npx @gitterm/discord-bot` and keep `.gitterm-bot/` on persistent storage.
|
|
241
|
+
|
|
242
|
+
Run one bot process per Discord application. Two processes with the same token would both answer.
|
|
243
|
+
|
|
244
|
+
## Security
|
|
245
|
+
|
|
246
|
+
- **Who can use it:** anyone who can post in a channel the bot can see, unless you list
|
|
247
|
+
`allowedUsers`. Only those people can then make requests and approve the agent's tool requests.
|
|
248
|
+
Use channel permissions for the rest.
|
|
249
|
+
- **What the agent can reach:** whatever the attached GitTerm connections allow. Scope the GitHub
|
|
250
|
+
connection and MCP credentials accordingly, and use OpenCode `permission` rules (via
|
|
251
|
+
`workspace.opencode.config`) to deny commands outright.
|
|
252
|
+
- **What the bot stores:** GitTerm workspace and run ids, written with mode `0600`. No tokens.
|
|
253
|
+
- Agent replies never ping anyone, and only images are forwarded to the agent.
|
|
254
|
+
|
|
255
|
+
## Limits
|
|
256
|
+
|
|
257
|
+
- The agent is OpenCode. T3 Code workspaces have no run API for bots to drive.
|
|
258
|
+
- A question left unanswered past the workspace's idle timeout lets the sandbox pause, which
|
|
259
|
+
cancels the run.
|
|
260
|
+
- Direct messages are not supported; talk to the bot in server channels.
|
|
261
|
+
|
|
262
|
+
See [`@gitterm/bot`](https://www.npmjs.com/package/@gitterm/bot) to put the same agent behind another chat platform.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ChatAdapter } from "@gitterm/bot";
|
|
2
|
+
import { Client } from "discord.js";
|
|
3
|
+
export type DiscordAdapterOptions = {
|
|
4
|
+
/** Bot token from the Discord developer portal. Defaults to `DISCORD_BOT_TOKEN`. */
|
|
5
|
+
token?: string;
|
|
6
|
+
/**
|
|
7
|
+
* A discord.js client you already run, to add the agent to an existing bot. It needs the
|
|
8
|
+
* Guilds, GuildMessages, and MessageContent intents. You log it in and destroy it yourself;
|
|
9
|
+
* `start()` resolves once it is ready. Make sure your own handlers do not also answer the same
|
|
10
|
+
* mentions.
|
|
11
|
+
*/
|
|
12
|
+
client?: Client;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Discord over the gateway: no public URL needed. Mention the bot in a channel and it opens a
|
|
16
|
+
* thread for the conversation; every thread is one agent session.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createDiscordAdapter(options?: DiscordAdapterOptions): ChatAdapter;
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { BOT_ENV_HELP, cliOptions, hasRepository } from "@gitterm/bot";
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
import { createBot, withSavedConfig as loadSaved } from "@gitterm/bot";
|
|
8
|
+
|
|
9
|
+
// src/adapter.ts
|
|
10
|
+
import { once } from "node:events";
|
|
11
|
+
import { downloadImage } from "@gitterm/bot";
|
|
12
|
+
import {
|
|
13
|
+
Client,
|
|
14
|
+
Events,
|
|
15
|
+
GatewayIntentBits,
|
|
16
|
+
IntentsBitField,
|
|
17
|
+
MessageFlags,
|
|
18
|
+
PermissionsBitField,
|
|
19
|
+
ThreadAutoArchiveDuration
|
|
20
|
+
} from "discord.js";
|
|
21
|
+
|
|
22
|
+
// src/components.ts
|
|
23
|
+
import { splitMessage, tablesToCode } from "@gitterm/bot";
|
|
24
|
+
import {
|
|
25
|
+
ActionRowBuilder,
|
|
26
|
+
ButtonBuilder,
|
|
27
|
+
ButtonStyle,
|
|
28
|
+
EmbedBuilder,
|
|
29
|
+
ModalBuilder,
|
|
30
|
+
StringSelectMenuBuilder,
|
|
31
|
+
TextInputBuilder,
|
|
32
|
+
TextInputStyle
|
|
33
|
+
} from "discord.js";
|
|
34
|
+
var MESSAGE_LIMIT = 1900;
|
|
35
|
+
var BUTTONS_PER_ROW = 5;
|
|
36
|
+
var BUTTON_OPTION_LIMIT = 20;
|
|
37
|
+
var SELECT_LIMIT = 25;
|
|
38
|
+
var OPEN_COLOR = 15246141;
|
|
39
|
+
var SETTLED_COLOR = 12107719;
|
|
40
|
+
var truncate = (value, limit) => value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
|
|
41
|
+
var customId = (promptId, control, value) => ["gt", promptId, control, ...value === undefined ? [] : [String(value)]].join(":");
|
|
42
|
+
function parseCustomId(id) {
|
|
43
|
+
const [prefix, promptId, control, value] = id.split(":");
|
|
44
|
+
return prefix === "gt" && promptId && control ? { promptId, control, value } : null;
|
|
45
|
+
}
|
|
46
|
+
function answerContents(markdown, footer) {
|
|
47
|
+
const chunks = splitMessage(tablesToCode(markdown), MESSAGE_LIMIT);
|
|
48
|
+
const last = chunks.length - 1;
|
|
49
|
+
return chunks.map((chunk, index) => index === last ? `${chunk}
|
|
50
|
+
-# ${footer}` : chunk);
|
|
51
|
+
}
|
|
52
|
+
function embed(prompt, color) {
|
|
53
|
+
if (prompt.kind === "permission") {
|
|
54
|
+
const patterns = prompt.request.patterns.filter(Boolean).join(`
|
|
55
|
+
`);
|
|
56
|
+
return new EmbedBuilder().setColor(color).setTitle("Approval needed").setDescription(truncate(`The agent wants to run **${prompt.request.title}**${patterns ? `
|
|
57
|
+
\`\`\`
|
|
58
|
+
${patterns}
|
|
59
|
+
\`\`\`` : ""}`, 4000));
|
|
60
|
+
}
|
|
61
|
+
const { question } = prompt;
|
|
62
|
+
const counter = prompt.total > 1 ? `Question ${prompt.index + 1} of ${prompt.total} · ` : "";
|
|
63
|
+
const options = question.options.map((option, i) => `${i + 1}. **${option.label}**${option.description ? ` — ${option.description}` : ""}`).join(`
|
|
64
|
+
`);
|
|
65
|
+
return new EmbedBuilder().setColor(color).setTitle(truncate(`${counter}${question.header || "Question"}`, 256)).setDescription(truncate([`**${question.question}**`, options].filter(Boolean).join(`
|
|
66
|
+
|
|
67
|
+
`), 4000));
|
|
68
|
+
}
|
|
69
|
+
function controls(prompt) {
|
|
70
|
+
const rows = [];
|
|
71
|
+
if (prompt.kind === "permission") {
|
|
72
|
+
rows.push(new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(customId(prompt.id, "p", "once")).setLabel("Allow once").setStyle(ButtonStyle.Primary), new ButtonBuilder().setCustomId(customId(prompt.id, "p", "always")).setLabel("Always allow").setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId(customId(prompt.id, "p", "reject")).setLabel("Deny").setStyle(ButtonStyle.Danger)));
|
|
73
|
+
return rows;
|
|
74
|
+
}
|
|
75
|
+
const { question } = prompt;
|
|
76
|
+
const options = question.options;
|
|
77
|
+
if (options.length > 0 && (question.multiple || options.length > BUTTON_OPTION_LIMIT)) {
|
|
78
|
+
const shown = options.slice(0, SELECT_LIMIT);
|
|
79
|
+
rows.push(new ActionRowBuilder().addComponents(new StringSelectMenuBuilder().setCustomId(customId(prompt.id, "m")).setPlaceholder(question.multiple ? "Choose one or more" : "Choose one").setMinValues(1).setMaxValues(question.multiple ? shown.length : 1).addOptions(shown.map((option, i) => ({
|
|
80
|
+
label: truncate(option.label, 100),
|
|
81
|
+
value: String(i),
|
|
82
|
+
...option.description ? { description: truncate(option.description, 100) } : {}
|
|
83
|
+
})))));
|
|
84
|
+
} else {
|
|
85
|
+
for (let start = 0;start < options.length; start += BUTTONS_PER_ROW) {
|
|
86
|
+
rows.push(new ActionRowBuilder().addComponents(options.slice(start, start + BUTTONS_PER_ROW).map((option, i) => new ButtonBuilder().setCustomId(customId(prompt.id, "o", start + i)).setLabel(truncate(option.label, 80)).setStyle(ButtonStyle.Secondary))));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (question.custom) {
|
|
90
|
+
rows.push(new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(customId(prompt.id, "c")).setLabel("Write your own answer").setStyle(ButtonStyle.Primary)));
|
|
91
|
+
}
|
|
92
|
+
return rows;
|
|
93
|
+
}
|
|
94
|
+
function promptMessage(prompt) {
|
|
95
|
+
const hint = prompt.kind === "permission" ? "Or reply **yes**, **always**, or **no** in this thread." : prompt.question.options.length > 0 ? "Choose below or reply with the option's number." : "Reply in this thread or use the button.";
|
|
96
|
+
return {
|
|
97
|
+
content: `<@${prompt.requester.id}> ${hint}`,
|
|
98
|
+
embeds: [embed(prompt, OPEN_COLOR)],
|
|
99
|
+
components: controls(prompt),
|
|
100
|
+
allowedMentions: { users: [prompt.requester.id] }
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function settledMessage(prompt, outcome) {
|
|
104
|
+
return {
|
|
105
|
+
content: "",
|
|
106
|
+
embeds: [
|
|
107
|
+
embed(prompt, SETTLED_COLOR).addFields({ name: "Outcome", value: truncate(outcome, 1024) })
|
|
108
|
+
],
|
|
109
|
+
components: []
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function customAnswerModal(prompt) {
|
|
113
|
+
return new ModalBuilder().setCustomId(customId(prompt.id, "modal")).setTitle(truncate(prompt.question.header || "Your answer", 45)).addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("answer").setLabel(truncate(prompt.question.question, 45)).setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/adapter.ts
|
|
117
|
+
var INTENTS = [
|
|
118
|
+
GatewayIntentBits.Guilds,
|
|
119
|
+
GatewayIntentBits.GuildMessages,
|
|
120
|
+
GatewayIntentBits.MessageContent
|
|
121
|
+
];
|
|
122
|
+
var THREAD_NAME_LIMIT = 90;
|
|
123
|
+
var HISTORY_LIMIT = 100;
|
|
124
|
+
var RECENT_MESSAGES = 500;
|
|
125
|
+
var NO_MENTIONS = { parse: [] };
|
|
126
|
+
var INVITE_PERMISSIONS = new PermissionsBitField([
|
|
127
|
+
"ViewChannel",
|
|
128
|
+
"SendMessages",
|
|
129
|
+
"SendMessagesInThreads",
|
|
130
|
+
"CreatePublicThreads",
|
|
131
|
+
"ReadMessageHistory",
|
|
132
|
+
"AddReactions",
|
|
133
|
+
"EmbedLinks"
|
|
134
|
+
]);
|
|
135
|
+
var userOf = (message) => ({
|
|
136
|
+
id: message.author.id,
|
|
137
|
+
name: message.member?.displayName ?? message.author.displayName ?? message.author.username
|
|
138
|
+
});
|
|
139
|
+
var file = (attachment) => ({
|
|
140
|
+
id: attachment.id,
|
|
141
|
+
name: attachment.name,
|
|
142
|
+
mime: attachment.contentType?.split(";")[0] ?? "application/octet-stream",
|
|
143
|
+
size: attachment.size,
|
|
144
|
+
async download(signal) {
|
|
145
|
+
return downloadImage(attachment.url, {
|
|
146
|
+
hosts: ["cdn.discordapp.com", "media.discordapp.net"],
|
|
147
|
+
signal
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
async function onInteraction(events, interaction) {
|
|
152
|
+
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const parsed = parseCustomId(interaction.customId);
|
|
156
|
+
if (!parsed)
|
|
157
|
+
return;
|
|
158
|
+
const prompt = events.prompt(parsed.promptId);
|
|
159
|
+
const by = {
|
|
160
|
+
id: interaction.user.id,
|
|
161
|
+
name: (interaction.member && "displayName" in interaction.member ? interaction.member.displayName : undefined) ?? interaction.user.displayName
|
|
162
|
+
};
|
|
163
|
+
const stale = () => interaction.reply({ content: "This was already answered.", flags: MessageFlags.Ephemeral });
|
|
164
|
+
if (interaction.isButton() && parsed.control === "c") {
|
|
165
|
+
if (prompt?.kind !== "question")
|
|
166
|
+
return stale();
|
|
167
|
+
return interaction.showModal(customAnswerModal(prompt));
|
|
168
|
+
}
|
|
169
|
+
let answer;
|
|
170
|
+
if (prompt?.kind === "permission" && parsed.control === "p") {
|
|
171
|
+
answer = { kind: "permission", response: parsed.value };
|
|
172
|
+
} else if (prompt?.kind === "question") {
|
|
173
|
+
const labelAt = (value) => prompt.question.options[Number(value)]?.label;
|
|
174
|
+
const labels = interaction.isStringSelectMenu() && parsed.control === "m" ? interaction.values.map(labelAt) : interaction.isButton() && parsed.control === "o" ? [labelAt(parsed.value)] : interaction.isModalSubmit() && parsed.control === "modal" ? [interaction.fields.getTextInputValue("answer").trim()] : [];
|
|
175
|
+
const valid = labels.filter((label) => Boolean(label));
|
|
176
|
+
if (valid.length > 0)
|
|
177
|
+
answer = { kind: "question", labels: valid };
|
|
178
|
+
}
|
|
179
|
+
const outcome = answer ? events.answer(parsed.promptId, answer, by) : "stale";
|
|
180
|
+
if (outcome === "forbidden") {
|
|
181
|
+
return interaction.reply({
|
|
182
|
+
content: "Only people allowed to use this bot can answer.",
|
|
183
|
+
flags: MessageFlags.Ephemeral
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (outcome !== "answered")
|
|
187
|
+
return stale();
|
|
188
|
+
if (interaction.isModalSubmit() && !interaction.isFromMessage()) {
|
|
189
|
+
return interaction.reply({ content: "Sent to the agent.", flags: MessageFlags.Ephemeral });
|
|
190
|
+
}
|
|
191
|
+
return interaction.deferUpdate();
|
|
192
|
+
}
|
|
193
|
+
function createDiscordAdapter(options = {}) {
|
|
194
|
+
const owned = !options.client;
|
|
195
|
+
const token = owned ? (options.token ?? process.env.DISCORD_BOT_TOKEN)?.trim() : undefined;
|
|
196
|
+
if (owned && !token)
|
|
197
|
+
throw new Error("DISCORD_BOT_TOKEN is required");
|
|
198
|
+
if (options.client) {
|
|
199
|
+
const intents = new IntentsBitField(options.client.options.intents);
|
|
200
|
+
const missing = INTENTS.filter((intent) => !intents.has(intent));
|
|
201
|
+
if (missing.length > 0) {
|
|
202
|
+
throw new Error(`The Discord client needs the ${missing.map((intent) => GatewayIntentBits[intent]).join(", ")} intent(s)`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const client = options.client ?? new Client({ intents: INTENTS });
|
|
206
|
+
const recent = new Map;
|
|
207
|
+
const botId = () => client.user?.id ?? "";
|
|
208
|
+
const stripMention = (text) => text.replaceAll(`<@${botId()}>`, "").replaceAll(`<@!${botId()}>`, "").trim();
|
|
209
|
+
const thread = async (id) => {
|
|
210
|
+
const channel = await client.channels.fetch(id);
|
|
211
|
+
if (!channel?.isThread())
|
|
212
|
+
throw new Error(`Discord channel ${id} is not a thread`);
|
|
213
|
+
return channel;
|
|
214
|
+
};
|
|
215
|
+
async function onMessage(events, message) {
|
|
216
|
+
if (message.author.bot || !message.inGuild())
|
|
217
|
+
return;
|
|
218
|
+
const mentioned = message.mentions.users.has(botId()) || message.mentions.repliedUser?.id === botId();
|
|
219
|
+
const channel = message.channel;
|
|
220
|
+
let location;
|
|
221
|
+
if (channel.isThread()) {
|
|
222
|
+
if (!channel.parentId)
|
|
223
|
+
return;
|
|
224
|
+
location = { channel: channel.parentId, thread: channel.id };
|
|
225
|
+
} else {
|
|
226
|
+
if (!mentioned || !events.accepts(channel.id) || !("threads" in channel))
|
|
227
|
+
return;
|
|
228
|
+
const name = stripMention(message.content).slice(0, THREAD_NAME_LIMIT) || "GitTerm agent";
|
|
229
|
+
const opened = await message.startThread({
|
|
230
|
+
name,
|
|
231
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay
|
|
232
|
+
});
|
|
233
|
+
location = { channel: channel.id, thread: opened.id };
|
|
234
|
+
}
|
|
235
|
+
recent.set(message.id, message);
|
|
236
|
+
if (recent.size > RECENT_MESSAGES)
|
|
237
|
+
recent.delete(recent.keys().next().value);
|
|
238
|
+
events.message({
|
|
239
|
+
id: message.id,
|
|
240
|
+
thread: location,
|
|
241
|
+
author: userOf(message),
|
|
242
|
+
text: stripMention(message.content),
|
|
243
|
+
files: [...message.attachments.values()].map(file),
|
|
244
|
+
mentioned,
|
|
245
|
+
inThread: channel.isThread(),
|
|
246
|
+
tenant: message.guildId
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
platform: "discord",
|
|
251
|
+
displayName: "Discord",
|
|
252
|
+
async start(events) {
|
|
253
|
+
client.on(Events.MessageCreate, (message) => {
|
|
254
|
+
onMessage(events, message).catch((error) => console.error("Discord message handling failed", error));
|
|
255
|
+
});
|
|
256
|
+
client.on(Events.InteractionCreate, (interaction) => {
|
|
257
|
+
onInteraction(events, interaction).catch((error) => console.error("Discord interaction handling failed", error));
|
|
258
|
+
});
|
|
259
|
+
if (!client.isReady()) {
|
|
260
|
+
const ready = once(client, Events.ClientReady);
|
|
261
|
+
if (owned)
|
|
262
|
+
await client.login(token);
|
|
263
|
+
await ready;
|
|
264
|
+
}
|
|
265
|
+
const applicationId = client.application?.id ?? client.user?.id ?? "";
|
|
266
|
+
console.info(`Invite the bot: https://discord.com/oauth2/authorize?client_id=${applicationId}&scope=bot&permissions=${INVITE_PERMISSIONS.bitfield}`);
|
|
267
|
+
return { scope: applicationId };
|
|
268
|
+
},
|
|
269
|
+
async stop() {
|
|
270
|
+
if (owned)
|
|
271
|
+
await client.destroy();
|
|
272
|
+
},
|
|
273
|
+
async history(location, after) {
|
|
274
|
+
const channel = await thread(location.thread);
|
|
275
|
+
const fetched = await channel.messages.fetch({
|
|
276
|
+
limit: HISTORY_LIMIT,
|
|
277
|
+
...after ? { after } : {}
|
|
278
|
+
});
|
|
279
|
+
const messages = [...fetched.values()].toReversed();
|
|
280
|
+
if (!after) {
|
|
281
|
+
const starter = await channel.fetchStarterMessage().catch(() => null);
|
|
282
|
+
if (starter)
|
|
283
|
+
messages.unshift(starter);
|
|
284
|
+
}
|
|
285
|
+
return messages.map((message) => ({
|
|
286
|
+
id: message.id,
|
|
287
|
+
author: message.author.bot ? { id: message.author.id, name: "assistant" } : userOf(message),
|
|
288
|
+
fromBot: message.author.bot,
|
|
289
|
+
text: stripMention(message.content),
|
|
290
|
+
files: [...message.attachments.values()].map(file)
|
|
291
|
+
}));
|
|
292
|
+
},
|
|
293
|
+
async post(location, text) {
|
|
294
|
+
const sent = await (await thread(location.thread)).send({ content: text, allowedMentions: NO_MENTIONS });
|
|
295
|
+
return sent.id;
|
|
296
|
+
},
|
|
297
|
+
async edit(location, messageId, text) {
|
|
298
|
+
await (await thread(location.thread)).messages.edit(messageId, { content: text });
|
|
299
|
+
},
|
|
300
|
+
async remove(location, messageId) {
|
|
301
|
+
await (await thread(location.thread)).messages.delete(messageId);
|
|
302
|
+
},
|
|
303
|
+
async reply(location, markdown, footer, delivery) {
|
|
304
|
+
const channel = await thread(location.thread);
|
|
305
|
+
const contents = answerContents(markdown, footer);
|
|
306
|
+
for (let index = delivery?.sent ?? 0;index < contents.length; index++) {
|
|
307
|
+
await channel.send({ content: contents[index], allowedMentions: NO_MENTIONS });
|
|
308
|
+
await delivery?.recordSent(index + 1);
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
async ask(location, prompt) {
|
|
312
|
+
const sent = await (await thread(location.thread)).send(promptMessage(prompt));
|
|
313
|
+
return sent.id;
|
|
314
|
+
},
|
|
315
|
+
async settle(location, messageId, prompt, outcome) {
|
|
316
|
+
await (await thread(location.thread)).messages.edit(messageId, settledMessage(prompt, outcome));
|
|
317
|
+
},
|
|
318
|
+
async mark(message, state) {
|
|
319
|
+
await recent.get(message.id)?.react({ seen: "\uD83D\uDC40", done: "✅", failed: "❌" }[state]);
|
|
320
|
+
if (state !== "seen")
|
|
321
|
+
recent.delete(message.id);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/index.ts
|
|
327
|
+
function createDiscordBot(options) {
|
|
328
|
+
const { token, client, ...bot } = options;
|
|
329
|
+
return createBot({
|
|
330
|
+
...bot,
|
|
331
|
+
adapter: createDiscordAdapter({ ...token ? { token } : {}, ...client ? { client } : {} })
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
async function withSavedConfig(options = {}) {
|
|
335
|
+
return { ...options, ...await loadSaved(options, "discord", options.logger) };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// src/cli.ts
|
|
339
|
+
var USAGE = `Usage: gitterm-discord-bot [-h] [--repo <url>[#branch]] [--model <provider/model>]
|
|
340
|
+
[--instructions-file <path>]
|
|
341
|
+
|
|
342
|
+
Environment (a .env file in the working directory is loaded):
|
|
343
|
+
DISCORD_BOT_TOKEN Bot token; enable the Message Content intent
|
|
344
|
+
${BOT_ENV_HELP}
|
|
345
|
+
|
|
346
|
+
The invite link for your server is printed when the bot starts.`;
|
|
347
|
+
var { command, options: local } = cliOptions();
|
|
348
|
+
if (command === "help" || !hasRepository(local) && !process.env.GITTERM_API_TOKEN) {
|
|
349
|
+
console.log(USAGE);
|
|
350
|
+
process.exitCode = command === "help" ? 0 : 1;
|
|
351
|
+
} else {
|
|
352
|
+
const options = await withSavedConfig(local);
|
|
353
|
+
if (!hasRepository(options)) {
|
|
354
|
+
console.log(USAGE);
|
|
355
|
+
console.error(`
|
|
356
|
+
No repository: set GITTERM_BOT_REPO, or save this bot under Bots in GitTerm.`);
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
const bot = createDiscordBot(options);
|
|
360
|
+
await bot.start();
|
|
361
|
+
const shutdown = () => void bot.stop().finally(() => process.exit(0));
|
|
362
|
+
process.once("SIGINT", shutdown);
|
|
363
|
+
process.once("SIGTERM", shutdown);
|
|
364
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ChatPrompt } from "@gitterm/bot";
|
|
2
|
+
import { ActionRowBuilder, ButtonBuilder, EmbedBuilder, ModalBuilder, StringSelectMenuBuilder } from "discord.js";
|
|
3
|
+
/** `gt:<prompt id>:<control>[:<value>]`; Discord allows 100 characters. */
|
|
4
|
+
export declare const customId: (promptId: string, control: string, value?: string | number) => string;
|
|
5
|
+
export declare function parseCustomId(id: string): {
|
|
6
|
+
promptId: string;
|
|
7
|
+
control: string;
|
|
8
|
+
value: string | undefined;
|
|
9
|
+
} | null;
|
|
10
|
+
/** The agent's answer as message contents; the last carries the footer as subtext. */
|
|
11
|
+
export declare function answerContents(markdown: string, footer: string): string[];
|
|
12
|
+
export declare function promptMessage(prompt: ChatPrompt): {
|
|
13
|
+
content: string;
|
|
14
|
+
embeds: EmbedBuilder[];
|
|
15
|
+
components: (ActionRowBuilder<ButtonBuilder> | ActionRowBuilder<StringSelectMenuBuilder>)[];
|
|
16
|
+
allowedMentions: {
|
|
17
|
+
users: string[];
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export declare function settledMessage(prompt: ChatPrompt, outcome: string): {
|
|
21
|
+
content: string;
|
|
22
|
+
embeds: EmbedBuilder[];
|
|
23
|
+
components: never[];
|
|
24
|
+
};
|
|
25
|
+
export declare function customAnswerModal(prompt: Extract<ChatPrompt, {
|
|
26
|
+
kind: "question";
|
|
27
|
+
}>): ModalBuilder;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Bot, type BotOptions } from "@gitterm/bot";
|
|
2
|
+
import { type DiscordAdapterOptions } from "./adapter.js";
|
|
3
|
+
export { createDiscordAdapter, type DiscordAdapterOptions } from "./adapter.js";
|
|
4
|
+
export type { Bot, RepoTarget, WorkspaceOverrides } from "@gitterm/bot";
|
|
5
|
+
export type DiscordBotOptions = Omit<BotOptions, "adapter"> & DiscordAdapterOptions;
|
|
6
|
+
/**
|
|
7
|
+
* A Discord bot that runs a coding agent on your repository in a GitTerm sandbox.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* await createDiscordBot({ repo: "https://github.com/acme/app" }).start();
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Tokens default to `DISCORD_BOT_TOKEN` and `GITTERM_API_TOKEN`; pass `client` to add the agent
|
|
14
|
+
* to a discord.js client you already run.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createDiscordBot(options: DiscordBotOptions): Bot;
|
|
17
|
+
/**
|
|
18
|
+
* Adds the settings saved for this bot under Bots in the GitTerm dashboard, found by its API
|
|
19
|
+
* token. Anything you pass wins: `createDiscordBot(await withSavedConfig({ ... }))`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function withSavedConfig(options?: DiscordBotOptions): Promise<DiscordBotOptions>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createBot, withSavedConfig as loadSaved } from "@gitterm/bot";
|
|
3
|
+
|
|
4
|
+
// src/adapter.ts
|
|
5
|
+
import { once } from "node:events";
|
|
6
|
+
import { downloadImage } from "@gitterm/bot";
|
|
7
|
+
import {
|
|
8
|
+
Client,
|
|
9
|
+
Events,
|
|
10
|
+
GatewayIntentBits,
|
|
11
|
+
IntentsBitField,
|
|
12
|
+
MessageFlags,
|
|
13
|
+
PermissionsBitField,
|
|
14
|
+
ThreadAutoArchiveDuration
|
|
15
|
+
} from "discord.js";
|
|
16
|
+
|
|
17
|
+
// src/components.ts
|
|
18
|
+
import { splitMessage, tablesToCode } from "@gitterm/bot";
|
|
19
|
+
import {
|
|
20
|
+
ActionRowBuilder,
|
|
21
|
+
ButtonBuilder,
|
|
22
|
+
ButtonStyle,
|
|
23
|
+
EmbedBuilder,
|
|
24
|
+
ModalBuilder,
|
|
25
|
+
StringSelectMenuBuilder,
|
|
26
|
+
TextInputBuilder,
|
|
27
|
+
TextInputStyle
|
|
28
|
+
} from "discord.js";
|
|
29
|
+
var MESSAGE_LIMIT = 1900;
|
|
30
|
+
var BUTTONS_PER_ROW = 5;
|
|
31
|
+
var BUTTON_OPTION_LIMIT = 20;
|
|
32
|
+
var SELECT_LIMIT = 25;
|
|
33
|
+
var OPEN_COLOR = 15246141;
|
|
34
|
+
var SETTLED_COLOR = 12107719;
|
|
35
|
+
var truncate = (value, limit) => value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
|
|
36
|
+
var customId = (promptId, control, value) => ["gt", promptId, control, ...value === undefined ? [] : [String(value)]].join(":");
|
|
37
|
+
function parseCustomId(id) {
|
|
38
|
+
const [prefix, promptId, control, value] = id.split(":");
|
|
39
|
+
return prefix === "gt" && promptId && control ? { promptId, control, value } : null;
|
|
40
|
+
}
|
|
41
|
+
function answerContents(markdown, footer) {
|
|
42
|
+
const chunks = splitMessage(tablesToCode(markdown), MESSAGE_LIMIT);
|
|
43
|
+
const last = chunks.length - 1;
|
|
44
|
+
return chunks.map((chunk, index) => index === last ? `${chunk}
|
|
45
|
+
-# ${footer}` : chunk);
|
|
46
|
+
}
|
|
47
|
+
function embed(prompt, color) {
|
|
48
|
+
if (prompt.kind === "permission") {
|
|
49
|
+
const patterns = prompt.request.patterns.filter(Boolean).join(`
|
|
50
|
+
`);
|
|
51
|
+
return new EmbedBuilder().setColor(color).setTitle("Approval needed").setDescription(truncate(`The agent wants to run **${prompt.request.title}**${patterns ? `
|
|
52
|
+
\`\`\`
|
|
53
|
+
${patterns}
|
|
54
|
+
\`\`\`` : ""}`, 4000));
|
|
55
|
+
}
|
|
56
|
+
const { question } = prompt;
|
|
57
|
+
const counter = prompt.total > 1 ? `Question ${prompt.index + 1} of ${prompt.total} · ` : "";
|
|
58
|
+
const options = question.options.map((option, i) => `${i + 1}. **${option.label}**${option.description ? ` — ${option.description}` : ""}`).join(`
|
|
59
|
+
`);
|
|
60
|
+
return new EmbedBuilder().setColor(color).setTitle(truncate(`${counter}${question.header || "Question"}`, 256)).setDescription(truncate([`**${question.question}**`, options].filter(Boolean).join(`
|
|
61
|
+
|
|
62
|
+
`), 4000));
|
|
63
|
+
}
|
|
64
|
+
function controls(prompt) {
|
|
65
|
+
const rows = [];
|
|
66
|
+
if (prompt.kind === "permission") {
|
|
67
|
+
rows.push(new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(customId(prompt.id, "p", "once")).setLabel("Allow once").setStyle(ButtonStyle.Primary), new ButtonBuilder().setCustomId(customId(prompt.id, "p", "always")).setLabel("Always allow").setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId(customId(prompt.id, "p", "reject")).setLabel("Deny").setStyle(ButtonStyle.Danger)));
|
|
68
|
+
return rows;
|
|
69
|
+
}
|
|
70
|
+
const { question } = prompt;
|
|
71
|
+
const options = question.options;
|
|
72
|
+
if (options.length > 0 && (question.multiple || options.length > BUTTON_OPTION_LIMIT)) {
|
|
73
|
+
const shown = options.slice(0, SELECT_LIMIT);
|
|
74
|
+
rows.push(new ActionRowBuilder().addComponents(new StringSelectMenuBuilder().setCustomId(customId(prompt.id, "m")).setPlaceholder(question.multiple ? "Choose one or more" : "Choose one").setMinValues(1).setMaxValues(question.multiple ? shown.length : 1).addOptions(shown.map((option, i) => ({
|
|
75
|
+
label: truncate(option.label, 100),
|
|
76
|
+
value: String(i),
|
|
77
|
+
...option.description ? { description: truncate(option.description, 100) } : {}
|
|
78
|
+
})))));
|
|
79
|
+
} else {
|
|
80
|
+
for (let start = 0;start < options.length; start += BUTTONS_PER_ROW) {
|
|
81
|
+
rows.push(new ActionRowBuilder().addComponents(options.slice(start, start + BUTTONS_PER_ROW).map((option, i) => new ButtonBuilder().setCustomId(customId(prompt.id, "o", start + i)).setLabel(truncate(option.label, 80)).setStyle(ButtonStyle.Secondary))));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (question.custom) {
|
|
85
|
+
rows.push(new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(customId(prompt.id, "c")).setLabel("Write your own answer").setStyle(ButtonStyle.Primary)));
|
|
86
|
+
}
|
|
87
|
+
return rows;
|
|
88
|
+
}
|
|
89
|
+
function promptMessage(prompt) {
|
|
90
|
+
const hint = prompt.kind === "permission" ? "Or reply **yes**, **always**, or **no** in this thread." : prompt.question.options.length > 0 ? "Choose below or reply with the option's number." : "Reply in this thread or use the button.";
|
|
91
|
+
return {
|
|
92
|
+
content: `<@${prompt.requester.id}> ${hint}`,
|
|
93
|
+
embeds: [embed(prompt, OPEN_COLOR)],
|
|
94
|
+
components: controls(prompt),
|
|
95
|
+
allowedMentions: { users: [prompt.requester.id] }
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function settledMessage(prompt, outcome) {
|
|
99
|
+
return {
|
|
100
|
+
content: "",
|
|
101
|
+
embeds: [
|
|
102
|
+
embed(prompt, SETTLED_COLOR).addFields({ name: "Outcome", value: truncate(outcome, 1024) })
|
|
103
|
+
],
|
|
104
|
+
components: []
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function customAnswerModal(prompt) {
|
|
108
|
+
return new ModalBuilder().setCustomId(customId(prompt.id, "modal")).setTitle(truncate(prompt.question.header || "Your answer", 45)).addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("answer").setLabel(truncate(prompt.question.question, 45)).setStyle(TextInputStyle.Paragraph).setRequired(true)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/adapter.ts
|
|
112
|
+
var INTENTS = [
|
|
113
|
+
GatewayIntentBits.Guilds,
|
|
114
|
+
GatewayIntentBits.GuildMessages,
|
|
115
|
+
GatewayIntentBits.MessageContent
|
|
116
|
+
];
|
|
117
|
+
var THREAD_NAME_LIMIT = 90;
|
|
118
|
+
var HISTORY_LIMIT = 100;
|
|
119
|
+
var RECENT_MESSAGES = 500;
|
|
120
|
+
var NO_MENTIONS = { parse: [] };
|
|
121
|
+
var INVITE_PERMISSIONS = new PermissionsBitField([
|
|
122
|
+
"ViewChannel",
|
|
123
|
+
"SendMessages",
|
|
124
|
+
"SendMessagesInThreads",
|
|
125
|
+
"CreatePublicThreads",
|
|
126
|
+
"ReadMessageHistory",
|
|
127
|
+
"AddReactions",
|
|
128
|
+
"EmbedLinks"
|
|
129
|
+
]);
|
|
130
|
+
var userOf = (message) => ({
|
|
131
|
+
id: message.author.id,
|
|
132
|
+
name: message.member?.displayName ?? message.author.displayName ?? message.author.username
|
|
133
|
+
});
|
|
134
|
+
var file = (attachment) => ({
|
|
135
|
+
id: attachment.id,
|
|
136
|
+
name: attachment.name,
|
|
137
|
+
mime: attachment.contentType?.split(";")[0] ?? "application/octet-stream",
|
|
138
|
+
size: attachment.size,
|
|
139
|
+
async download(signal) {
|
|
140
|
+
return downloadImage(attachment.url, {
|
|
141
|
+
hosts: ["cdn.discordapp.com", "media.discordapp.net"],
|
|
142
|
+
signal
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
async function onInteraction(events, interaction) {
|
|
147
|
+
if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const parsed = parseCustomId(interaction.customId);
|
|
151
|
+
if (!parsed)
|
|
152
|
+
return;
|
|
153
|
+
const prompt = events.prompt(parsed.promptId);
|
|
154
|
+
const by = {
|
|
155
|
+
id: interaction.user.id,
|
|
156
|
+
name: (interaction.member && "displayName" in interaction.member ? interaction.member.displayName : undefined) ?? interaction.user.displayName
|
|
157
|
+
};
|
|
158
|
+
const stale = () => interaction.reply({ content: "This was already answered.", flags: MessageFlags.Ephemeral });
|
|
159
|
+
if (interaction.isButton() && parsed.control === "c") {
|
|
160
|
+
if (prompt?.kind !== "question")
|
|
161
|
+
return stale();
|
|
162
|
+
return interaction.showModal(customAnswerModal(prompt));
|
|
163
|
+
}
|
|
164
|
+
let answer;
|
|
165
|
+
if (prompt?.kind === "permission" && parsed.control === "p") {
|
|
166
|
+
answer = { kind: "permission", response: parsed.value };
|
|
167
|
+
} else if (prompt?.kind === "question") {
|
|
168
|
+
const labelAt = (value) => prompt.question.options[Number(value)]?.label;
|
|
169
|
+
const labels = interaction.isStringSelectMenu() && parsed.control === "m" ? interaction.values.map(labelAt) : interaction.isButton() && parsed.control === "o" ? [labelAt(parsed.value)] : interaction.isModalSubmit() && parsed.control === "modal" ? [interaction.fields.getTextInputValue("answer").trim()] : [];
|
|
170
|
+
const valid = labels.filter((label) => Boolean(label));
|
|
171
|
+
if (valid.length > 0)
|
|
172
|
+
answer = { kind: "question", labels: valid };
|
|
173
|
+
}
|
|
174
|
+
const outcome = answer ? events.answer(parsed.promptId, answer, by) : "stale";
|
|
175
|
+
if (outcome === "forbidden") {
|
|
176
|
+
return interaction.reply({
|
|
177
|
+
content: "Only people allowed to use this bot can answer.",
|
|
178
|
+
flags: MessageFlags.Ephemeral
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (outcome !== "answered")
|
|
182
|
+
return stale();
|
|
183
|
+
if (interaction.isModalSubmit() && !interaction.isFromMessage()) {
|
|
184
|
+
return interaction.reply({ content: "Sent to the agent.", flags: MessageFlags.Ephemeral });
|
|
185
|
+
}
|
|
186
|
+
return interaction.deferUpdate();
|
|
187
|
+
}
|
|
188
|
+
function createDiscordAdapter(options = {}) {
|
|
189
|
+
const owned = !options.client;
|
|
190
|
+
const token = owned ? (options.token ?? process.env.DISCORD_BOT_TOKEN)?.trim() : undefined;
|
|
191
|
+
if (owned && !token)
|
|
192
|
+
throw new Error("DISCORD_BOT_TOKEN is required");
|
|
193
|
+
if (options.client) {
|
|
194
|
+
const intents = new IntentsBitField(options.client.options.intents);
|
|
195
|
+
const missing = INTENTS.filter((intent) => !intents.has(intent));
|
|
196
|
+
if (missing.length > 0) {
|
|
197
|
+
throw new Error(`The Discord client needs the ${missing.map((intent) => GatewayIntentBits[intent]).join(", ")} intent(s)`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const client = options.client ?? new Client({ intents: INTENTS });
|
|
201
|
+
const recent = new Map;
|
|
202
|
+
const botId = () => client.user?.id ?? "";
|
|
203
|
+
const stripMention = (text) => text.replaceAll(`<@${botId()}>`, "").replaceAll(`<@!${botId()}>`, "").trim();
|
|
204
|
+
const thread = async (id) => {
|
|
205
|
+
const channel = await client.channels.fetch(id);
|
|
206
|
+
if (!channel?.isThread())
|
|
207
|
+
throw new Error(`Discord channel ${id} is not a thread`);
|
|
208
|
+
return channel;
|
|
209
|
+
};
|
|
210
|
+
async function onMessage(events, message) {
|
|
211
|
+
if (message.author.bot || !message.inGuild())
|
|
212
|
+
return;
|
|
213
|
+
const mentioned = message.mentions.users.has(botId()) || message.mentions.repliedUser?.id === botId();
|
|
214
|
+
const channel = message.channel;
|
|
215
|
+
let location;
|
|
216
|
+
if (channel.isThread()) {
|
|
217
|
+
if (!channel.parentId)
|
|
218
|
+
return;
|
|
219
|
+
location = { channel: channel.parentId, thread: channel.id };
|
|
220
|
+
} else {
|
|
221
|
+
if (!mentioned || !events.accepts(channel.id) || !("threads" in channel))
|
|
222
|
+
return;
|
|
223
|
+
const name = stripMention(message.content).slice(0, THREAD_NAME_LIMIT) || "GitTerm agent";
|
|
224
|
+
const opened = await message.startThread({
|
|
225
|
+
name,
|
|
226
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay
|
|
227
|
+
});
|
|
228
|
+
location = { channel: channel.id, thread: opened.id };
|
|
229
|
+
}
|
|
230
|
+
recent.set(message.id, message);
|
|
231
|
+
if (recent.size > RECENT_MESSAGES)
|
|
232
|
+
recent.delete(recent.keys().next().value);
|
|
233
|
+
events.message({
|
|
234
|
+
id: message.id,
|
|
235
|
+
thread: location,
|
|
236
|
+
author: userOf(message),
|
|
237
|
+
text: stripMention(message.content),
|
|
238
|
+
files: [...message.attachments.values()].map(file),
|
|
239
|
+
mentioned,
|
|
240
|
+
inThread: channel.isThread(),
|
|
241
|
+
tenant: message.guildId
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
platform: "discord",
|
|
246
|
+
displayName: "Discord",
|
|
247
|
+
async start(events) {
|
|
248
|
+
client.on(Events.MessageCreate, (message) => {
|
|
249
|
+
onMessage(events, message).catch((error) => console.error("Discord message handling failed", error));
|
|
250
|
+
});
|
|
251
|
+
client.on(Events.InteractionCreate, (interaction) => {
|
|
252
|
+
onInteraction(events, interaction).catch((error) => console.error("Discord interaction handling failed", error));
|
|
253
|
+
});
|
|
254
|
+
if (!client.isReady()) {
|
|
255
|
+
const ready = once(client, Events.ClientReady);
|
|
256
|
+
if (owned)
|
|
257
|
+
await client.login(token);
|
|
258
|
+
await ready;
|
|
259
|
+
}
|
|
260
|
+
const applicationId = client.application?.id ?? client.user?.id ?? "";
|
|
261
|
+
console.info(`Invite the bot: https://discord.com/oauth2/authorize?client_id=${applicationId}&scope=bot&permissions=${INVITE_PERMISSIONS.bitfield}`);
|
|
262
|
+
return { scope: applicationId };
|
|
263
|
+
},
|
|
264
|
+
async stop() {
|
|
265
|
+
if (owned)
|
|
266
|
+
await client.destroy();
|
|
267
|
+
},
|
|
268
|
+
async history(location, after) {
|
|
269
|
+
const channel = await thread(location.thread);
|
|
270
|
+
const fetched = await channel.messages.fetch({
|
|
271
|
+
limit: HISTORY_LIMIT,
|
|
272
|
+
...after ? { after } : {}
|
|
273
|
+
});
|
|
274
|
+
const messages = [...fetched.values()].toReversed();
|
|
275
|
+
if (!after) {
|
|
276
|
+
const starter = await channel.fetchStarterMessage().catch(() => null);
|
|
277
|
+
if (starter)
|
|
278
|
+
messages.unshift(starter);
|
|
279
|
+
}
|
|
280
|
+
return messages.map((message) => ({
|
|
281
|
+
id: message.id,
|
|
282
|
+
author: message.author.bot ? { id: message.author.id, name: "assistant" } : userOf(message),
|
|
283
|
+
fromBot: message.author.bot,
|
|
284
|
+
text: stripMention(message.content),
|
|
285
|
+
files: [...message.attachments.values()].map(file)
|
|
286
|
+
}));
|
|
287
|
+
},
|
|
288
|
+
async post(location, text) {
|
|
289
|
+
const sent = await (await thread(location.thread)).send({ content: text, allowedMentions: NO_MENTIONS });
|
|
290
|
+
return sent.id;
|
|
291
|
+
},
|
|
292
|
+
async edit(location, messageId, text) {
|
|
293
|
+
await (await thread(location.thread)).messages.edit(messageId, { content: text });
|
|
294
|
+
},
|
|
295
|
+
async remove(location, messageId) {
|
|
296
|
+
await (await thread(location.thread)).messages.delete(messageId);
|
|
297
|
+
},
|
|
298
|
+
async reply(location, markdown, footer, delivery) {
|
|
299
|
+
const channel = await thread(location.thread);
|
|
300
|
+
const contents = answerContents(markdown, footer);
|
|
301
|
+
for (let index = delivery?.sent ?? 0;index < contents.length; index++) {
|
|
302
|
+
await channel.send({ content: contents[index], allowedMentions: NO_MENTIONS });
|
|
303
|
+
await delivery?.recordSent(index + 1);
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
async ask(location, prompt) {
|
|
307
|
+
const sent = await (await thread(location.thread)).send(promptMessage(prompt));
|
|
308
|
+
return sent.id;
|
|
309
|
+
},
|
|
310
|
+
async settle(location, messageId, prompt, outcome) {
|
|
311
|
+
await (await thread(location.thread)).messages.edit(messageId, settledMessage(prompt, outcome));
|
|
312
|
+
},
|
|
313
|
+
async mark(message, state) {
|
|
314
|
+
await recent.get(message.id)?.react({ seen: "\uD83D\uDC40", done: "✅", failed: "❌" }[state]);
|
|
315
|
+
if (state !== "seen")
|
|
316
|
+
recent.delete(message.id);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/index.ts
|
|
322
|
+
function createDiscordBot(options) {
|
|
323
|
+
const { token, client, ...bot } = options;
|
|
324
|
+
return createBot({
|
|
325
|
+
...bot,
|
|
326
|
+
adapter: createDiscordAdapter({ ...token ? { token } : {}, ...client ? { client } : {} })
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
async function withSavedConfig(options = {}) {
|
|
330
|
+
return { ...options, ...await loadSaved(options, "discord", options.logger) };
|
|
331
|
+
}
|
|
332
|
+
export {
|
|
333
|
+
createDiscordAdapter,
|
|
334
|
+
createDiscordBot,
|
|
335
|
+
withSavedConfig
|
|
336
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gitterm/discord-bot",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Discord bot that runs a coding agent on your repository in a GitTerm sandbox",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent",
|
|
7
|
+
"bot",
|
|
8
|
+
"discord",
|
|
9
|
+
"gitterm",
|
|
10
|
+
"opencode"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://gitterm.dev",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Opeyemi Oginni",
|
|
15
|
+
"bin": {
|
|
16
|
+
"gitterm-discord-bot": "dist/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "rm -rf dist && bun build src/index.ts src/cli.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.build.json",
|
|
36
|
+
"prepublishOnly": "bun run check-types && bun run build",
|
|
37
|
+
"check-types": "tsc --noEmit",
|
|
38
|
+
"start:local": "bun --watch src/cli.ts"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@gitterm/bot": "^0.1.0",
|
|
42
|
+
"@gitterm/sdk": "^0.9.0",
|
|
43
|
+
"discord.js": "^14.27.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/bun": "^1.2.6",
|
|
47
|
+
"typescript": "^5.8.2"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=22.12.0"
|
|
51
|
+
}
|
|
52
|
+
}
|