Initial barnacle setup

This commit is contained in:
Shadow 2026-01-23 18:00:53 -06:00
commit cb061ee3a2
No known key found for this signature in database
11 changed files with 378 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
dist/
.env

41
README.md Normal file
View File

@ -0,0 +1,41 @@
# barnacle
A Discord bot built with [Carbon](https://carbon.buape.com).
## Setup
1. Create a `.env` file with the following variables:
```env
BASE_URL="your-base-url"
DEPLOY_SECRET="your-deploy-secret"
DISCORD_CLIENT_ID="your-client-id"
DISCORD_PUBLIC_KEY="discord-public-key"
DISCORD_BOT_TOKEN="your-bot-token"
```
2. Install dependencies:
```bash
bun install
```
3. Start the development server:
```bash
bun run dev
```
## Commands
- `/github` - Look up an issue or PR (defaults to clawdbot/clawdbot)
## Gateway Events
The bot listens for the following Gateway events:
- AutoModeration Action Execution - Sends keyword-based responses
## AutoMod Responses
Edit `src/config/automod-messages.json` to map keywords to messages. Use `{user}` to mention the triggering user.
## License
MIT

47
bun.lock Normal file
View File

@ -0,0 +1,47 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "barnacle",
"dependencies": {
"@buape/carbon": "0.14.0",
},
"devDependencies": {
"@types/bun": "1.3.6",
"typescript": "5.9.3",
},
},
},
"packages": {
"@buape/carbon": ["@buape/carbon@0.14.0", "", { "dependencies": { "@types/node": "^25.0.9", "discord-api-types": "0.38.37" }, "optionalDependencies": { "@cloudflare/workers-types": "4.20260120.0", "@discordjs/voice": "0.19.0", "@hono/node-server": "1.19.9", "@types/bun": "1.3.6", "@types/ws": "8.18.1", "ws": "8.19.0" } }, "sha512-mavllPK2iVpRNRtC4C8JOUdJ1hdV0+LDelFW+pjpJaM31MBLMfIJ+f/LlYTIK5QrEcQsXOC+6lU2e0gmgjWhIQ=="],
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260120.0", "", {}, "sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw=="],
"@discordjs/voice": ["@discordjs/voice@0.19.0", "", { "dependencies": { "@types/ws": "^8.18.1", "discord-api-types": "^0.38.16", "prism-media": "^1.3.5", "tslib": "^2.8.1", "ws": "^8.18.3" } }, "sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw=="],
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"discord-api-types": ["discord-api-types@0.38.37", "", {}, "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w=="],
"hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="],
"prism-media": ["prism-media@1.3.5", "", { "peerDependencies": { "@discordjs/opus": ">=0.8.0 <1.0.0", "ffmpeg-static": "^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0", "node-opus": "^0.3.3", "opusscript": "^0.0.8" }, "optionalPeers": ["@discordjs/opus", "ffmpeg-static", "node-opus", "opusscript"] }, "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
}
}

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "barnacle",
"version": "1.0.0",
"description": "A Discord bot built with Carbon",
"type": "module",
"main": "./src/index.ts",
"scripts": {
"dev": "bun run . --watch",
"start": "bun run ."
},
"dependencies": {
"@buape/carbon": "0.14.0"
},
"devDependencies": {
"@types/bun": "1.3.6",
"typescript": "5.9.3"
}
}

108
src/commands/github.ts Normal file
View File

@ -0,0 +1,108 @@
import {
ApplicationCommandOptionType,
Command,
type CommandInteraction,
LinkButton,
Section,
TextDisplay
} from "@buape/carbon"
type GitHubIssue = {
html_url: string
number: number
title?: string
state?: string
pull_request?: {
url: string
}
}
class GitHubLinkButton extends LinkButton {
label = "Open on GitHub"
url: string
constructor(url: string) {
super()
this.url = url
}
}
export default class GithubCommand extends Command {
name = "github"
description = "Find a GitHub issue or pull request"
defer = true
options = [
{
name: "number",
description: "Issue or pull request number",
type: ApplicationCommandOptionType.Integer,
required: true
},
{
name: "user",
description: "Repository owner (default: clawdbot)",
type: ApplicationCommandOptionType.String
},
{
name: "repo",
description: "Repository name (default: clawdbot)",
type: ApplicationCommandOptionType.String
}
]
async run(interaction: CommandInteraction) {
const number = interaction.options.getInteger("number", true)
const owner = interaction.options.getString("user") ?? "clawdbot"
const repo = interaction.options.getString("repo") ?? "clawdbot"
const repoName = `${owner}/${repo}`
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/issues/${number}`
let response: Response
try {
response = await fetch(apiUrl, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "barnacle"
}
})
} catch (error) {
await interaction.reply({
components: [
new TextDisplay(`Failed to reach GitHub for ${repoName}.`)
]
})
return
}
if (!response.ok) {
const message =
response.status === 404
? `No issue or pull request #${number} found in ${repoName}.`
: `GitHub returned ${response.status} for ${repoName}.`
await interaction.reply({
components: [new TextDisplay(message)]
})
return
}
const issue = (await response.json()) as GitHubIssue
const typeLabel = issue.pull_request ? "Pull request" : "Issue"
const title = issue.title ?? "No title available"
const state = issue.state ?? "unknown"
await interaction.reply({
components: [
new Section(
[
new TextDisplay(`${typeLabel} #${issue.number} in ${repoName}`),
new TextDisplay(title),
new TextDisplay(`State: ${state}`)
],
new GitHubLinkButton(issue.html_url)
)
]
})
}
}

View File

@ -0,0 +1,3 @@
{
"example": "{user} that keyword isn't allowed here."
}

20
src/events/authorized.ts Normal file
View File

@ -0,0 +1,20 @@
import {
ApplicationIntegrationType,
ListenerEvent,
type Client,
ApplicationAuthorizedListener,
type ListenerEventData
} from "@buape/carbon"
export default class ApplicationAuthorized extends ApplicationAuthorizedListener {
async handle(
data: ListenerEventData[typeof ListenerEvent.ApplicationAuthorized],
client: Client
) {
if (data.integration_type === ApplicationIntegrationType.GuildInstall) {
console.log(`Added to server ${data.guild?.name} (${data.guild?.id})`)
} else {
console.log(`Added to user ${data.user.username} (${data.user.id})`)
}
}
}

View File

@ -0,0 +1,70 @@
import {
AutoModerationActionExecutionListener,
type Client,
type ListenerEventData,
ListenerEvent,
Routes,
TextDisplay,
serializePayload
} from "@buape/carbon"
import { readFile } from "node:fs/promises"
type AutomodMessageMap = Record<string, string>
type AutoModerationActionExecutionData =
ListenerEventData[typeof ListenerEvent.AutoModerationActionExecution]
const automodMessagesUrl = new URL("../config/automod-messages.json", import.meta.url)
const normalizeKeyword = (keyword: string) => keyword.trim().toLowerCase()
const loadAutomodMessages = async (): Promise<AutomodMessageMap> => {
try {
const raw = await readFile(automodMessagesUrl, "utf8")
return JSON.parse(raw) as AutomodMessageMap
} catch (error) {
console.error("Failed to load automod messages:", error)
return {}
}
}
const formatAutomodMessage = (template: string, data: AutoModerationActionExecutionData) =>
template
.replaceAll("{user}", `<@${data.user_id}>`)
.replaceAll("{keyword}", data.matched_keyword ?? "")
.replaceAll("{content}", data.matched_content ?? data.content ?? "")
export default class AutoModerationActionExecution extends AutoModerationActionExecutionListener {
async handle(data: ListenerEventData[this["type"]], client: Client) {
if (!data.channel_id || !data.matched_keyword) {
return
}
const messages = await loadAutomodMessages()
const keyword = normalizeKeyword(data.matched_keyword)
const normalizedMessages = Object.fromEntries(
Object.entries(messages).map(([key, value]) => [normalizeKeyword(key), value])
)
const template = normalizedMessages[keyword]
if (!template) {
return
}
const content = formatAutomodMessage(template, data)
const payload = serializePayload({
components: [new TextDisplay(content)],
allowedMentions: {
users: [data.user_id]
}
})
try {
await client.rest.post(Routes.channelMessages(data.channel_id), {
body: payload
})
} catch (error) {
console.error("Failed to send automod response:", error)
}
}
}

11
src/events/ready.ts Normal file
View File

@ -0,0 +1,11 @@
import {
type Client,
ReadyListener,
type ListenerEventData
} from "@buape/carbon"
export default class Ready extends ReadyListener {
async handle(data: ListenerEventData[this["type"]], client: Client) {
console.log(`Logged in as ${data.user.username}`)
}
}

44
src/index.ts Normal file
View File

@ -0,0 +1,44 @@
import { Client } from "@buape/carbon"
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"
import { createServer } from "@buape/carbon/adapters/bun"
import GithubCommand from "./commands/github.js"
import ApplicationAuthorized from "./events/authorized.js"
import AutoModerationActionExecution from "./events/autoModerationActionExecution.js"
const gateway = new GatewayPlugin({
intents:
GatewayIntents.Guilds |
GatewayIntents.GuildMessages |
GatewayIntents.MessageContent |
GatewayIntents.AutoModerationExecution
})
const client = new Client(
{
baseUrl: process.env.BASE_URL,
deploySecret: process.env.DEPLOY_SECRET,
clientId: process.env.DISCORD_CLIENT_ID,
publicKey: process.env.DISCORD_PUBLIC_KEY,
token: process.env.DISCORD_BOT_TOKEN,
devGuilds: process.env.DISCORD_DEV_GUILDS?.split(","), // Optional: comma-separated list of dev guild IDs
},
{
commands: [new GithubCommand()],
listeners: [new ApplicationAuthorized(), new AutoModerationActionExecution()],
},
[gateway]
)
createServer(client, { port: 3000 })
declare global {
namespace NodeJS {
interface ProcessEnv {
BASE_URL: string;
DEPLOY_SECRET: string;
DISCORD_CLIENT_ID: string;
DISCORD_PUBLIC_KEY: string;
DISCORD_BOT_TOKEN: string;
}
}
}

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"include": ["src"],
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"strict": true,
"skipLibCheck": true,
"lib": ["dom", "es2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"baseUrl": ".",
"outDir": "dist"
}
}