Initial barnacle setup
This commit is contained in:
commit
cb061ee3a2
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
41
README.md
Normal file
41
README.md
Normal 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
47
bun.lock
Normal 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
18
package.json
Normal 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
108
src/commands/github.ts
Normal 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)
|
||||
)
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
3
src/config/automod-messages.json
Normal file
3
src/config/automod-messages.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"example": "{user} that keyword isn't allowed here."
|
||||
}
|
||||
20
src/events/authorized.ts
Normal file
20
src/events/authorized.ts
Normal 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})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/events/autoModerationActionExecution.ts
Normal file
70
src/events/autoModerationActionExecution.ts
Normal 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
11
src/events/ready.ts
Normal 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
44
src/index.ts
Normal 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
13
tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user