@gitterm/discord-bot 0.0.0-stage → 0.1.1

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 CHANGED
@@ -1,3 +1,262 @@
1
- # Temporary Holding Version
1
+ # @gitterm/discord-bot
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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;
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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.1",
42
+ "@gitterm/sdk": "^0.10.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
+ }