1 Commits

Author SHA1 Message Date
be0a944665 Delete .github directory 2024-04-01 02:20:41 -06:00
89 changed files with 720 additions and 192083 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -1,10 +0,0 @@
# flyctl launch added from .gitignore
# Environment variables
**/.env
**/.env.local
**/.env.development.local
**/.env.test.local
**/.env.production.local
**/.env
**/himbot
fly.toml

View File

@@ -1,32 +1,11 @@
# Discord Configuration
# Tokens
DISCORD_TOKEN=""
ADMIN_USER_IDS="123456789012345678"
# Container configuration
IMAGE=""
ROOT_DIR=""
# Himbucks System Configuration
HIMBUCKS_PER_REWARD=10
MESSAGE_COUNT_THRESHOLD=5
HIMBUCKS_COOLDOWN_MINUTES=1
# Markov Chain Configuration
MARKOV_DEFAULT_MESSAGES=500
MARKOV_MAX_MESSAGES=1000
MARKOV_CACHE_SIZE=50
MARKOV_MAX_NGRAM=5
MARKOV_MEMORY_LIMIT_MB=100
# Database Configuration
DB_MAX_OPEN_CONNS=25
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME_MINUTES=5
# Command Cooldowns (in seconds)
PING_COOLDOWN_SECONDS=5
HS_COOLDOWN_SECONDS=10
MARKOV_COOLDOWN_SECONDS=30
HIMBUCKS_COOLDOWN_SECONDS=5
HIMBOARD_COOLDOWN_SECONDS=5
SENDBUCKS_COOLDOWN_SECONDS=1800
REPLICATE_API_TOKEN=""
# Comma separated
COOLDOWN_ALLOW_LIST=""
# S3
BUCKET_NAME=
AWS_ENDPOINT_URL_S3=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=

1
.envrc
View File

@@ -1 +0,0 @@
use flake

View File

@@ -1,35 +0,0 @@
name: Docker Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Container Registry
uses: docker/login-action@v2
with:
registry: ${{ secrets.REPO_HOST }}
username: ${{ github.repository_owner }}
password: ${{ secrets.DEPLOY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64
push: true
tags: |
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest

8
.gitignore vendored
View File

@@ -6,11 +6,3 @@
.env.production.local
.env
himbot
*.db
*.db-client_wal_index
*.db-shm
*.db-wal
# nix
.direnv/
result

View File

@@ -1,35 +1,14 @@
# Build stage
FROM golang:1.25.5 AS build
FROM golang:1.22.0 as build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -ldflags="-s -w" -o /go/bin/app
RUN go mod download
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /go/bin/app
# Final stage
FROM ubuntu:24.04
FROM gcr.io/distroless/base-debian12
# Install SSL certificates and required runtime libraries
RUN apt-get update && apt-get install -y \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /go/bin/app /
# Create app directory
WORKDIR /app
# Copy the binary
COPY --from=build /go/bin/app /app/himbot
# Copy migrations directory
COPY --from=build /app/migrations /app/migrations
# Copy datasets directory
COPY --from=build /app/datasets /app/datasets
# Set the entrypoint
ENTRYPOINT ["/app/himbot"]
CMD [ "/app" ]

View File

@@ -4,13 +4,14 @@ A discord bot written in Go.
## It's dangerous to go alone! Take this!
- Install Go 1.25.5 or higher (required)
- Install Go 1.21.5 or higher (required)
## Running Locally
- Copy .env.example and rename to .env
- Create a Discord Bot with all gateway permissions enabled
- Generate a token for this discord bot and paste it in the .env for DISCORD_TOKEN
- Generate and provide an Replicate token and paste it in the .env for REPLICATE_API_TOKEN
- Run `go run main.go` to run locally
## Adding the bot to a server

View File

@@ -1,66 +0,0 @@
package main
import (
"encoding/gob"
"flag"
"himbot/lib"
"log"
"os"
"path/filepath"
"strings"
)
func main() {
inputDir := flag.String("input", "datasets/bard", "Directory containing text files to train on")
outputFile := flag.String("output", "datasets/bard.gob", "Output file path for the pre-trained model")
order := flag.Int("order", 3, "Markov chain order (N-gram size)")
flag.Parse()
log.Printf("Scanning directory: %s", *inputDir)
var allLines []string
fileCount := 0
err := filepath.Walk(*inputDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".txt") {
content, err := os.ReadFile(path)
if err != nil {
log.Printf("Error reading file %s: %v", path, err)
return nil // Continue to next file
}
lines := strings.Split(string(content), "\n")
allLines = append(allLines, lines...)
fileCount++
if fileCount%5 == 0 {
log.Printf("Processed %d files...", fileCount)
}
}
return nil
})
if err != nil {
log.Fatalf("Error walking directory: %v", err)
}
log.Printf("Found %d files with %d total lines. Building Markov chain...", fileCount, len(allLines))
chain := lib.BuildMarkovChain(allLines, *order)
log.Printf("Chain built with %d start keys. Saving to %s...", len(chain.Starts), *outputFile)
f, err := os.Create(*outputFile)
if err != nil {
log.Fatalf("Failed to create output file: %v", err)
}
defer f.Close()
encoder := gob.NewEncoder(f)
if err := encoder.Encode(chain); err != nil {
log.Fatalf("Failed to encode chain: %v", err)
}
log.Println("Done!")
}

64
command/ask.go Normal file
View File

@@ -0,0 +1,64 @@
package command
import (
"bytes"
"context"
"errors"
"fmt"
"himbot/lib"
"time"
"github.com/diamondburned/arikawa/v3/api"
"github.com/diamondburned/arikawa/v3/api/cmdroute"
"github.com/diamondburned/arikawa/v3/utils/json/option"
"github.com/diamondburned/arikawa/v3/utils/sendpart"
)
func Ask(ctx context.Context, data cmdroute.CommandData) *api.InteractionResponseData {
// Cooldown Logic
allowed, cooldownString := lib.CooldownHandler(*data.Event, "ask", time.Minute)
if !allowed {
return lib.ErrorResponse(errors.New(cooldownString))
}
// Command Logic
var options struct {
Prompt string `discord:"prompt"`
}
if err := data.Options.Unmarshal(&options); err != nil {
lib.CancelTimer(data.Event.Member.User.ID.String(), "ask")
return lib.ErrorResponse(err)
}
respString, err := lib.ReplicateTextGeneration(options.Prompt)
if err != nil {
fmt.Printf("ChatCompletion error: %v\n", err)
lib.CancelTimer(data.Event.Member.User.ID.String(), "ask")
return &api.InteractionResponseData{
Content: option.NewNullableString("ChatCompletion Error!"),
AllowedMentions: &api.AllowedMentions{},
}
}
if len(respString) > 1800 {
textFile := bytes.NewBuffer([]byte(respString))
file := sendpart.File{
Name: "himbot_response.md",
Reader: textFile,
}
return &api.InteractionResponseData{
Content: option.NewNullableString("Prompt: " + options.Prompt + "\n"),
AllowedMentions: &api.AllowedMentions{},
Files: []sendpart.File{file},
}
}
return &api.InteractionResponseData{
Content: option.NewNullableString("Prompt: " + options.Prompt + "\n--------------------\n" + respString),
AllowedMentions: &api.AllowedMentions{},
}
}

View File

@@ -1,156 +0,0 @@
package command
import (
"fmt"
"himbot/lib"
"strings"
"github.com/bwmarrin/discordgo"
)
func BalanceGetCommand(s *discordgo.Session, i *discordgo.InteractionCreate) (string, error) {
user, err := lib.GetUser(i)
if err != nil {
return "", err
}
balance, err := lib.GetBalance(user.ID, i.GuildID)
if err != nil {
return "", err
}
return fmt.Sprintf("💸 You have %d Himbucks! 💸", balance), nil
}
func LeaderboardCommand(s *discordgo.Session, i *discordgo.InteractionCreate) (string, error) {
entries, err := lib.GetLeaderboard(i.GuildID, 10)
if err != nil {
return "", err
}
if len(entries) == 0 {
return "No himbucks earned yet!", nil
}
var sb strings.Builder
sb.WriteString("🏆 Himbucks Leaderboard 🏆\n\n")
for idx, entry := range entries {
sb.WriteString(fmt.Sprintf("%d. %s: %d himbucks\n", idx+1, entry.Username, entry.Balance))
}
return sb.String(), nil
}
func BalanceSendCommand(s *discordgo.Session, i *discordgo.InteractionCreate) (string, error) {
options := i.ApplicationCommandData().Options
// Discord handles the user mention/tag and provides the correct user ID
var recipientID string
var amount int
for _, opt := range options {
switch opt.Name {
case "user":
recipientID = opt.UserValue(nil).ID
case "amount":
amount = int(opt.IntValue())
}
}
// Validate amount
if amount <= 0 {
return "", fmt.Errorf("amount must be positive")
}
// Get sender's info
sender, err := lib.GetUser(i)
if err != nil {
return "", fmt.Errorf("failed to get sender info: %w", err)
}
// Don't allow sending to self
if sender.ID == recipientID {
return "", fmt.Errorf("you cannot send himbucks to yourself")
}
// Get recipient's info
recipient, err := s.User(recipientID)
if err != nil {
return "", fmt.Errorf("failed to get recipient info: %w", err)
}
// Send the himbucks
err = lib.SendBalance(sender.ID, recipientID, i.GuildID, amount)
if err != nil {
return "", fmt.Errorf("failed to send himbucks: %w", err)
}
return fmt.Sprintf("💸 Successfully sent %d Himbucks to %s! 💸", amount, recipient.Username), nil
}
func GiveCommand(s *discordgo.Session, i *discordgo.InteractionCreate) (string, error) {
if !isAdmin(i.Member.User.ID) {
s.InteractionResponseDelete(i.Interaction)
s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{
Content: "You do not have permission to use this command.",
Flags: discordgo.MessageFlagsEphemeral,
})
return "", nil
}
options := i.ApplicationCommandData().Options
var recipientID string
var amount int
for _, opt := range options {
switch opt.Name {
case "user":
recipientID = opt.UserValue(nil).ID
case "amount":
amount = int(opt.IntValue())
}
}
recipient, err := s.User(recipientID)
if err != nil {
return "", fmt.Errorf("failed to get recipient info: %w", err)
}
_, err = lib.GetOrCreateUserWithGuild(recipientID, recipient.Username, i.GuildID)
if err != nil {
return "", fmt.Errorf("failed to initialize recipient profile: %w", err)
}
err = lib.GiveHimbucks(recipientID, i.GuildID, amount)
if err != nil {
return "", fmt.Errorf("failed to give himbucks: %w", err)
}
action := "given to"
if amount < 0 {
action = "taken from"
amount = -amount
}
// Delete public "thinking" message
s.InteractionResponseDelete(i.Interaction)
// Send ephemeral success message
s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{
Content: fmt.Sprintf("Successfully %s %s %d Himbucks.", action, recipient.Username, amount),
Flags: discordgo.MessageFlagsEphemeral,
})
return "", nil
}
func isAdmin(userID string) bool {
for _, adminID := range lib.AppConfig.AdminIDs {
if userID == adminID {
return true
}
}
return false
}

View File

@@ -1,25 +1,26 @@
package command
import (
"fmt"
"context"
"himbot/lib"
"github.com/bwmarrin/discordgo"
"github.com/diamondburned/arikawa/v3/api"
"github.com/diamondburned/arikawa/v3/api/cmdroute"
"github.com/diamondburned/arikawa/v3/utils/json/option"
)
func HsCommand(s *discordgo.Session, i *discordgo.InteractionCreate) (string, error) {
options := i.ApplicationCommandData().Options
if len(options) == 0 || options[0].Type != discordgo.ApplicationCommandOptionString {
return "", fmt.Errorf("please provide a nickname")
}
nickname := options[0].StringValue()
user, err := lib.GetUser(i)
if err != nil {
return "", fmt.Errorf("error processing command: %w", err)
func HS(ctx context.Context, data cmdroute.CommandData) *api.InteractionResponseData {
var options struct {
Arg string `discord:"nickname"`
}
response := fmt.Sprintf("%s was %s's nickname in high school!", nickname, user.Username)
if err := data.Options.Unmarshal(&options); err != nil {
return lib.ErrorResponse(err)
}
return response, nil
user := lib.GetUserObject(*data.Event)
return &api.InteractionResponseData{
Content: option.NewNullableString(options.Arg + " was " + user.DisplayName() + "'s nickname in highschool!"),
}
}

View File

@@ -1,189 +0,0 @@
package command
import (
"crypto/md5"
"encoding/gob"
"fmt"
"himbot/lib"
"os"
"strings"
"sync"
"github.com/bwmarrin/discordgo"
)
type MarkovCache struct {
data map[string]*lib.MarkovData
hashes map[string]string
mu sync.RWMutex
}
var (
markovCache = &MarkovCache{
data: make(map[string]*lib.MarkovData),
hashes: make(map[string]string),
}
bardChain *lib.MarkovData
)
func InitBard(modelPath string) error {
f, err := os.Open(modelPath)
if err != nil {
return err
}
defer f.Close()
var data lib.MarkovData
decoder := gob.NewDecoder(f)
if err := decoder.Decode(&data); err != nil {
return err
}
bardChain = &data
return nil
}
func BardCommand(s *discordgo.Session, i *discordgo.InteractionCreate) (string, error) {
if bardChain == nil {
return "The bard is sleeping (dataset not loaded).", nil
}
var question string
for _, option := range i.ApplicationCommandData().Options {
if option.Name == "question" {
question = option.StringValue()
}
}
answer := lib.GenerateMessage(bardChain, question)
if answer == "" {
answer = "Words fail me."
}
if question != "" {
return fmt.Sprintf("**Q:** %s\n**A:** %s", question, answer), nil
}
return answer, nil
}
func MarkovQuestionCommand(s *discordgo.Session, i *discordgo.InteractionCreate) (string, error) {
channelID := i.ChannelID
var question string
numMessages := lib.AppConfig.MarkovDefaultMessages
for _, option := range i.ApplicationCommandData().Options {
switch option.Name {
case "question":
question = option.StringValue()
case "messages":
numMessages = int(option.IntValue())
if numMessages <= 0 {
numMessages = lib.AppConfig.MarkovDefaultMessages
} else if numMessages > lib.AppConfig.MarkovMaxMessages {
numMessages = lib.AppConfig.MarkovMaxMessages
}
}
}
if question == "" {
return "Please provide a question!", nil
}
cacheKey := fmt.Sprintf("%s:%d", channelID, numMessages)
var data *lib.MarkovData
if cachedData := getCachedChain(cacheKey); cachedData != nil {
data = cachedData
} else {
allMessages, err := fetchMessages(s, channelID, numMessages)
if err != nil {
return "", err
}
var texts []string
for _, msg := range allMessages {
texts = append(texts, msg.Content)
}
// Use order 2 for chat history (sparse data)
data = lib.BuildMarkovChain(texts, 2)
setCachedChain(cacheKey, data, hashMessages(allMessages))
}
answer := lib.GenerateMessage(data, question)
if answer == "" {
answer = "I don't have enough context to answer that."
}
return fmt.Sprintf("**Q:** %s\n**A:** %s", question, answer), nil
}
func getCachedChain(cacheKey string) *lib.MarkovData {
markovCache.mu.RLock()
defer markovCache.mu.RUnlock()
return markovCache.data[cacheKey]
}
func setCachedChain(cacheKey string, data *lib.MarkovData, hash string) {
markovCache.mu.Lock()
defer markovCache.mu.Unlock()
if len(data.Starts) > 0 {
markovCache.data[cacheKey] = data
markovCache.hashes[cacheKey] = hash
if len(markovCache.data) > lib.AppConfig.MarkovCacheSize {
for k := range markovCache.data {
delete(markovCache.data, k)
delete(markovCache.hashes, k)
break
}
}
}
}
func hashMessages(messages []*discordgo.Message) string {
var content strings.Builder
for _, msg := range messages {
content.WriteString(msg.ID)
}
return fmt.Sprintf("%x", md5.Sum([]byte(content.String())))
}
func fetchMessages(s *discordgo.Session, channelID string, numMessages int) ([]*discordgo.Message, error) {
var allMessages []*discordgo.Message
var lastMessageID string
allMessages = make([]*discordgo.Message, 0, numMessages)
for len(allMessages) < numMessages {
batchSize := 100
if numMessages-len(allMessages) < 100 {
batchSize = numMessages - len(allMessages)
}
batch, err := s.ChannelMessages(channelID, batchSize, lastMessageID, "", "")
if err != nil {
return nil, err
}
if len(batch) == 0 {
break
}
for _, msg := range batch {
if !msg.Author.Bot && len(strings.TrimSpace(msg.Content)) > 0 {
allMessages = append(allMessages, msg)
}
}
lastMessageID = batch[len(batch)-1].ID
if len(batch) < 100 {
break
}
}
return allMessages, nil
}

56
command/pic.go Normal file
View File

@@ -0,0 +1,56 @@
package command
import (
"context"
"errors"
"himbot/lib"
"strconv"
"time"
"github.com/diamondburned/arikawa/v3/api"
"github.com/diamondburned/arikawa/v3/api/cmdroute"
"github.com/diamondburned/arikawa/v3/utils/json/option"
"github.com/diamondburned/arikawa/v3/utils/sendpart"
)
func Pic(ctx context.Context, data cmdroute.CommandData) *api.InteractionResponseData {
// Cooldown Logic
allowed, cooldownString := lib.CooldownHandler(*data.Event, "pic", time.Minute*5)
if !allowed {
return lib.ErrorResponse(errors.New(cooldownString))
}
// Command Logic
var options struct {
Prompt string `discord:"prompt"`
}
if err := data.Options.Unmarshal(&options); err != nil {
lib.CancelTimer(data.Event.Member.User.ID.String(), "pic")
return lib.ErrorResponse(err)
}
// Get current epoch timestamp
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
// Concatenate clean username and timestamp to form filename
filename := data.Event.Sender().Username + "_" + timestamp + ".jpg"
imageFile, err := lib.ReplicateImageGeneration(options.Prompt, filename)
if err != nil {
lib.CancelTimer(data.Event.Member.User.ID.String(), "pic")
return lib.ErrorResponse(err)
}
file := sendpart.File{
Name: filename,
Reader: imageFile,
}
return &api.InteractionResponseData{
Content: option.NewNullableString("Prompt: " + options.Prompt),
Files: []sendpart.File{file},
}
}

View File

@@ -1,12 +1,16 @@
package command
import (
"github.com/bwmarrin/discordgo"
"context"
"github.com/diamondburned/arikawa/v3/api"
"github.com/diamondburned/arikawa/v3/api/cmdroute"
"github.com/diamondburned/arikawa/v3/utils/json/option"
)
func PingCommand(s *discordgo.Session, i *discordgo.InteractionCreate) (string, error) {
// Customize the response based on whether it's a guild or DM
responseContent := "Pong!"
return responseContent, nil
func Ping(ctx context.Context, data cmdroute.CommandData) *api.InteractionResponseData {
// Command Logic
return &api.InteractionResponseData{
Content: option.NewNullableString("Pong!"),
}
}

View File

@@ -1,59 +0,0 @@
package command
import (
"fmt"
"himbot/lib"
"github.com/bwmarrin/discordgo"
)
func ShopCommand(s *discordgo.Session, i *discordgo.InteractionCreate) (string, error) {
options := i.ApplicationCommandData().Options
if len(options) == 0 {
return "Welcome to the Himbucks Shop! Use `/shop buy` to purchase items.", nil
}
if options[0].Name == "buy" {
subOptions := options[0].Options
if len(subOptions) == 0 {
return "Please specify an item to buy.", nil
}
item := subOptions[0].StringValue()
user, err := lib.GetUser(i)
if err != nil {
return "", err
}
switch item {
case "pizza":
cost, newBalance, err := lib.BuyPizza(user.ID, i.GuildID)
if err != nil {
return fmt.Sprintf("❌ %s", err.Error()), nil
}
return fmt.Sprintf("Here is your pizza, %s!\n**Cost:** %d himbucks\n**Remaining Balance:** %d himbucks\n```\n%s\n```", user.Username, cost, newBalance, getPizzaArt()), nil
case "multiplier":
newMult, cost, newBalance, err := lib.BuyMultiplier(user.ID, i.GuildID)
if err != nil {
return fmt.Sprintf("❌ %s", err.Error()), nil
}
return fmt.Sprintf("Multiplier upgraded!\nYou spent **%d** himbucks.\nYour new earning multiplier is **%.1fx**!\n**Remaining Balance:** %d himbucks", cost, newMult, newBalance), nil
default:
return fmt.Sprintf("Unknown item: %s", item), nil
}
}
return "Unknown subcommand.", nil
}
func getPizzaArt() string {
return `
// ""--.._
|| (_) _ "-._
|| _ (_) '-.
|| (_) __..-'
\__..--"`
}

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,102 +0,0 @@
The Phoenix and Turtle
by William Shakespeare
Edited by Barbara A. Mowat and Paul Werstine
with Michael Poston and Rebecca Niles
Folger Shakespeare Library
https://shakespeare.folger.edu/shakespeares-works/the-phoenix-and-turtle/
Created on Jul 31, 2015, from FDT version 0.9.0.1
"The Phoenix and Turtle"
Let the bird of loudest lay
On the sole Arabian tree
Herald sad and trumpet be,
To whose sound chaste wings obey.
But thou shrieking harbinger,
Foul precurrer of the fiend,
Augur of the fever's end,
To this troop come thou not near.
From this session interdict
Every fowl of tyrant wing,
Save the eagle, feathered king;
Keep the obsequy so strict.
Let the priest in surplice white,
That defunctive music can,
Be the death-divining swan,
Lest the requiem lack his right.
And thou treble-dated crow,
That thy sable gender mak'st
With the breath thou giv'st and tak'st,
'Mongst our mourners shalt thou go.
Here the anthem doth commence:
Love and constancy is dead,
Phoenix and the turtle fled
In a mutual flame from hence.
So they loved, as love in twain
Had the essence but in one,
Two distincts, division none;
Number there in love was slain.
Hearts remote yet not asunder,
Distance and no space was seen
'Twixt this turtle and his queen;
But in them it were a wonder.
So between them love did shine
That the turtle saw his right
Flaming in the phoenix' sight;
Either was the other's mine.
Property was thus appalled
That the self was not the same;
Single nature's double name
Neither two nor one was called.
Reason, in itself confounded,
Saw division grow together,
To themselves yet either neither,
Simple were so well compounded
That it cried, "How true a twain
Seemeth this concordant one!
Love hath reason, Reason none,
If what parts can so remain,"
Whereupon it made this threne
To the phoenix and the dove,
Co-supremes and stars of love,
As chorus to their tragic scene.
Threnos
Beauty, truth, and rarity,
Grace in all simplicity,
Here enclosed, in cinders lie.
Death is now the phoenix' nest,
And the turtle's loyal breast
To eternity doth rest,
Leaving no posterity;
'Twas not their infirmity,
It was married chastity.
Truth may seem, but cannot be;
Beauty brag, but 'tis not she;
Truth and beauty buried be.
To this urn let those repair
That are either true or fair;
For these dead birds sigh a prayer.
William Shakespeare

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +0,0 @@
services:
server:
image: ${IMAGE}
ports:
- "3117:3000"
environment:
# Discord Configuration
- DISCORD_TOKEN=${DISCORD_TOKEN}
- ADMIN_USER_IDS=${ADMIN_USER_IDS}
# Himbucks System Configuration
- HIMBUCKS_PER_REWARD=${HIMBUCKS_PER_REWARD:-10}
- MESSAGE_COUNT_THRESHOLD=${MESSAGE_COUNT_THRESHOLD:-5}
- HIMBUCKS_COOLDOWN_MINUTES=${HIMBUCKS_COOLDOWN_MINUTES:-1}
# Markov Chain Configuration
- MARKOV_DEFAULT_MESSAGES=${MARKOV_DEFAULT_MESSAGES:-100}
- MARKOV_MAX_MESSAGES=${MARKOV_MAX_MESSAGES:-1000}
- MARKOV_CACHE_SIZE=${MARKOV_CACHE_SIZE:-10}
- MARKOV_MAX_NGRAM=${MARKOV_MAX_NGRAM:-5}
- MARKOV_MEMORY_LIMIT_MB=${MARKOV_MEMORY_LIMIT_MB:-100}
# Database Configuration
- DB_MAX_OPEN_CONNS=${DB_MAX_OPEN_CONNS:-25}
- DB_MAX_IDLE_CONNS=${DB_MAX_IDLE_CONNS:-5}
- DB_CONN_MAX_LIFETIME_MINUTES=${DB_CONN_MAX_LIFETIME_MINUTES:-5}
# Command Cooldowns (in seconds)
- PING_COOLDOWN_SECONDS=${PING_COOLDOWN_SECONDS:-5}
- HS_COOLDOWN_SECONDS=${HS_COOLDOWN_SECONDS:-10}
- MARKOV_COOLDOWN_SECONDS=${MARKOV_COOLDOWN_SECONDS:-30}
- HIMBUCKS_COOLDOWN_SECONDS=${HIMBUCKS_COOLDOWN_SECONDS:-5}
- HIMBOARD_COOLDOWN_SECONDS=${HIMBOARD_COOLDOWN_SECONDS:-5}
- SENDBUCKS_COOLDOWN_SECONDS=${SENDBUCKS_COOLDOWN_SECONDS:-1800}
volumes:
- ${ROOT_DIR}:/data

27
flake.lock generated
View File

@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1766473571,
"narHash": "sha256-5G1NDO2PulBx1RoaA6U1YoUDX0qZslpPxv+n5GX6Qto=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "76701a179d3a98b07653e2b0409847499b2a07d3",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,38 +0,0 @@
{
description = "himbot dev shell";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
};
outputs = { self, nixpkgs }:
let
allSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f {
pkgs = import nixpkgs { inherit system; };
});
in
{
devShells = forAllSystems ({ pkgs }: {
default = pkgs.mkShell {
packages = with pkgs; [
go
gopls
gotools
go-tools
];
shellHook = ''
echo "<himbot dev shell>"
echo "Go version: $(go version)"
'';
};
});
};
}

15
fly.toml Normal file
View File

@@ -0,0 +1,15 @@
# fly.toml app configuration file generated for himbot on 2023-10-19T18:34:44-03:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = "himbot"
primary_region = "sea"
swap_size_mb = 512
[http_service]
internal_port = 3000
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 1
processes = ["app"]

20
go.mod
View File

@@ -1,18 +1,20 @@
module himbot
go 1.25.5
go 1.22.0
require github.com/diamondburned/arikawa/v3 v3.3.5
require (
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240721121621-c0bdc870f11c // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/sys v0.40.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/sync v0.6.0 // indirect
)
require (
github.com/bwmarrin/discordgo v0.29.0
github.com/gorilla/websocket v1.5.3 // indirect
github.com/aws/aws-sdk-go v1.51.8
github.com/gorilla/schema v1.3.0 // indirect
github.com/gorilla/websocket v1.5.1 // indirect
github.com/joho/godotenv v1.5.1
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff
github.com/replicate/replicate-go v0.18.1
golang.org/x/time v0.5.0 // indirect
)

100
go.sum
View File

@@ -1,33 +1,83 @@
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/aws/aws-sdk-go v1.50.37 h1:gnAf6eYPSTb4QpVwugtWFqD07QXOoX7LewRrtLUx3lI=
github.com/aws/aws-sdk-go v1.50.37/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/aws/aws-sdk-go v1.51.3 h1:OqSyEXcJwf/XhZNVpMRgKlLA9nmbo5X8dwbll4RWxq8=
github.com/aws/aws-sdk-go v1.51.3/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/aws/aws-sdk-go v1.51.8 h1:tD7gQq5XKuKdhA6UMEH26ZNQH0s+HbL95rzv/ACz5TQ=
github.com/aws/aws-sdk-go v1.51.8/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/diamondburned/arikawa/v3 v3.3.5 h1:Z6BwetBMzPxTBLY2Ixxic2kdJJe0JhNvVrdbJ0gRcWg=
github.com/diamondburned/arikawa/v3 v3.3.5/go.mod h1:KPkkWr40xmEithhd15XD2dbkVY8A5+MCmZO0gRXk3qc=
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/schema v1.2.1 h1:tjDxcmdb+siIqkTNoV+qRH2mjYdr2hHe5MKXbp61ziM=
github.com/gorilla/schema v1.2.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/schema v1.3.0 h1:rbciOzXAx3IB8stEFnfTwO3sYa6EWlQk79XdyustPDA=
github.com/gorilla/schema v1.3.0/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240721121621-c0bdc870f11c h1:WsJ6G+hkDXIMfQE8FIxnnziT26WmsRgZhdWQ0IQGlcc=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240721121621-c0bdc870f11c/go.mod h1:gIcFddvsvPcRCO6QDmWH9/zcFd5U26QWWRMgZh4ddyo=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff h1:Hvxz9W8fWpSg9xkiq8/q+3cVJo+MmLMfkjdS/u4nWFY=
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/replicate/replicate-go v0.18.1 h1:4zduLVJxdQAoyl7zKj1e2nxwJVMcT6O/sXe6/eUEtns=
github.com/replicate/replicate-go v0.18.1/go.mod h1:D2x8SztjeUKcaYnSgVu3H2DechufLJWZJB4+TLA3Rag=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,65 +0,0 @@
package lib
import (
"time"
"github.com/bwmarrin/discordgo"
)
type CommandFunc func(s *discordgo.Session, i *discordgo.InteractionCreate) (string, error)
func HandleCommand(commandName string, cooldownDuration time.Duration, handler CommandFunc) func(s *discordgo.Session, i *discordgo.InteractionCreate) {
return func(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !CheckAndApplyCooldown(s, i, commandName, cooldownDuration) {
return
}
// Get user information (handle both guild and DM contexts)
user, userErr := GetUser(i)
if userErr != nil {
RespondWithError(s, i, "Error getting user information: "+userErr.Error())
return
}
// Get or create user and guild profile
_, createUserError := GetOrCreateUserWithGuild(user.ID, user.Username, i.GuildID)
if createUserError != nil {
RespondWithError(s, i, "Error creating user profile: "+createUserError.Error())
return
}
// Acknowledge the interaction immediately
interactErr := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
})
if interactErr != nil {
ThrowWithError(commandName, "Error deferring response: "+interactErr.Error())
return
}
// Execute the command handler
response, handlerErr := handler(s, i)
if handlerErr != nil {
s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{
Content: "Error processing command: " + handlerErr.Error(),
})
return
}
if response == "" {
return
}
// Send the follow-up message with the response
_, followErr := s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{
Content: response,
})
if followErr != nil {
ThrowWithError(commandName, "Error sending follow-up message: "+followErr.Error())
}
}
}

View File

@@ -1,127 +0,0 @@
package lib
import (
"os"
"strconv"
"time"
)
// Config holds all configuration values
type Config struct {
// Discord settings
DiscordToken string
AdminIDs []string
// Himbucks settings
HimbucksPerReward int
MessageCountThreshold int
CooldownPeriod time.Duration
// Markov settings
MarkovDefaultMessages int
MarkovMaxMessages int
MarkovCacheSize int
MarkovMaxNGram int // Maximum n-gram level (3, 4, 5, etc.)
MarkovMemoryLimit int // Memory limit in MB for n-gram chains
// Database settings
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
// Command cooldowns (in seconds)
PingCooldown int
HsCooldown int
MarkovCooldown int
MarkovAskCooldown int
HimbucksCooldown int
HimboardCooldown int
SendbucksCooldown int
ShopCooldown int
GivebucksCooldown int
}
var AppConfig *Config
// LoadConfig loads configuration from environment variables
func LoadConfig() *Config {
config := &Config{
// Discord settings
DiscordToken: getEnv("DISCORD_TOKEN", ""),
AdminIDs: getEnvSlice("ADMIN_USER_IDS", []string{}),
// Himbucks settings
HimbucksPerReward: getEnvInt("HIMBUCKS_PER_REWARD", 10),
MessageCountThreshold: getEnvInt("MESSAGE_COUNT_THRESHOLD", 5),
CooldownPeriod: time.Duration(getEnvInt("HIMBUCKS_COOLDOWN_MINUTES", 1)) * time.Minute,
// Markov settings
MarkovDefaultMessages: getEnvInt("MARKOV_DEFAULT_MESSAGES", 100),
MarkovMaxMessages: getEnvInt("MARKOV_MAX_MESSAGES", 1000),
MarkovCacheSize: getEnvInt("MARKOV_CACHE_SIZE", 10),
MarkovMaxNGram: getEnvInt("MARKOV_MAX_NGRAM", 5),
MarkovMemoryLimit: getEnvInt("MARKOV_MEMORY_LIMIT_MB", 100),
// Database settings
MaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 25),
MaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 5),
ConnMaxLifetime: time.Duration(getEnvInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute,
// Command cooldowns (in seconds)
PingCooldown: getEnvInt("PING_COOLDOWN_SECONDS", 5),
HsCooldown: getEnvInt("HS_COOLDOWN_SECONDS", 10),
MarkovCooldown: getEnvInt("MARKOV_COOLDOWN_SECONDS", 30),
MarkovAskCooldown: getEnvInt("MARKOV_ASK_COOLDOWN_SECONDS", 30),
HimbucksCooldown: getEnvInt("HIMBUCKS_COOLDOWN_SECONDS", 5),
HimboardCooldown: getEnvInt("HIMBOARD_COOLDOWN_SECONDS", 5),
SendbucksCooldown: getEnvInt("SENDBUCKS_COOLDOWN_SECONDS", 1800),
ShopCooldown: getEnvInt("SHOP_COOLDOWN_SECONDS", 5),
GivebucksCooldown: getEnvInt("GIVEBUCKS_COOLDOWN_SECONDS", 1),
}
AppConfig = config
return config
}
// getEnv gets an environment variable with a default value
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getEnvInt gets an environment variable as an integer with a default value
func getEnvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}
func getEnvSlice(key string, defaultValue []string) []string {
if value := os.Getenv(key); value != "" {
return importStrings(value)
}
return defaultValue
}
func importStrings(s string) []string {
var strings []string
current := ""
for _, c := range s {
if c == ',' {
strings = append(strings, current)
current = ""
} else {
current += string(c)
}
}
if current != "" {
strings = append(strings, current)
}
return strings
}

View File

@@ -1,66 +0,0 @@
package lib
import (
"fmt"
"sync"
"time"
"github.com/bwmarrin/discordgo"
)
var (
once sync.Once
instance *CooldownManager
)
type CooldownManager struct {
cooldowns map[string]time.Time
mu sync.Mutex
}
func GetCooldownManager() *CooldownManager {
once.Do(func() {
instance = &CooldownManager{
cooldowns: make(map[string]time.Time),
}
})
return instance
}
func (cm *CooldownManager) SetCooldown(userID, commandName string, duration time.Duration) {
cm.mu.Lock()
defer cm.mu.Unlock()
cm.cooldowns[userID+":"+commandName] = time.Now().Add(duration)
}
func (cm *CooldownManager) CheckCooldown(userID, commandName string) (bool, time.Duration) {
cm.mu.Lock()
defer cm.mu.Unlock()
key := userID + ":" + commandName
if cooldownEnd, exists := cm.cooldowns[key]; exists {
if time.Now().Before(cooldownEnd) {
return false, time.Until(cooldownEnd)
}
delete(cm.cooldowns, key)
}
return true, 0
}
func CheckAndApplyCooldown(s *discordgo.Session, i *discordgo.InteractionCreate, commandName string, duration time.Duration) bool {
cooldownManager := GetCooldownManager()
user, err := GetUser(i)
if err != nil {
RespondWithError(s, i, "Error processing command: "+err.Error())
return false
}
canUse, remaining := cooldownManager.CheckCooldown(user.ID, commandName)
if !canUse {
RespondWithError(s, i, fmt.Sprintf("You can use this command again in %v", remaining.Round(time.Second)))
return false
}
cooldownManager.SetCooldown(user.ID, commandName, duration)
return true
}

224
lib/db.go
View File

@@ -1,224 +0,0 @@
package lib
import (
"database/sql"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"sort"
"strings"
"github.com/tursodatabase/go-libsql"
)
var DBClient *sql.DB
var DBConnector *libsql.Connector
// Prepared statements
var (
stmtGetBalance *sql.Stmt
stmtUpdateBalance *sql.Stmt
stmtGetLeaderboard *sql.Stmt
stmtGetUserProfile *sql.Stmt
stmtUpdateProfile *sql.Stmt
)
func InitDB() error {
// Determine DB path based on /data directory existence
var dbPath string
if _, err := os.Stat("/data"); os.IsNotExist(err) {
dbPath = "file:./himbot.db"
} else {
dbPath = "file:/data/himbot.db"
}
db, err := sql.Open("libsql", dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to open db %s: %v", dbPath, err)
os.Exit(1)
}
// Configure connection pool using config values
db.SetMaxOpenConns(AppConfig.MaxOpenConns)
db.SetMaxIdleConns(AppConfig.MaxIdleConns)
db.SetConnMaxLifetime(AppConfig.ConnMaxLifetime)
// Test the connection
if err := db.Ping(); err != nil {
return fmt.Errorf("failed to ping database: %w", err)
}
DBClient = db
if err := runMigrations(); err != nil {
return err
}
// Prepare frequently used statements
return prepareStatements()
}
type Migration struct {
Version int
Up string
Down string
}
func loadMigrations() ([]Migration, error) {
var migrations []Migration
migrationFiles, err := filepath.Glob("migrations/*.up.sql")
if err != nil {
return nil, fmt.Errorf("failed to read migration files: %w", err)
}
for _, upFile := range migrationFiles {
// Extract version from filename (000001_create_users_table.up.sql -> 1)
baseName := filepath.Base(upFile)
version := 0
fmt.Sscanf(baseName, "%d_", &version)
downFile := strings.Replace(upFile, ".up.sql", ".down.sql", 1)
upSQL, err := ioutil.ReadFile(upFile)
if err != nil {
return nil, fmt.Errorf("failed to read migration file %s: %w", upFile, err)
}
downSQL, err := ioutil.ReadFile(downFile)
if err != nil {
return nil, fmt.Errorf("failed to read migration file %s: %w", downFile, err)
}
migrations = append(migrations, Migration{
Version: version,
Up: string(upSQL),
Down: string(downSQL),
})
}
sort.Slice(migrations, func(i, j int) bool {
return migrations[i].Version < migrations[j].Version
})
return migrations, nil
}
func runMigrations() error {
// Create migrations table if it doesn't exist
_, err := DBClient.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return fmt.Errorf("failed to create schema_migrations table: %w", err)
}
migrations, err := loadMigrations()
if err != nil {
return err
}
for _, migration := range migrations {
var exists bool
err := DBClient.QueryRow(
"SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = ?)",
migration.Version).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check migration status: %w", err)
}
if !exists {
tx, err := DBClient.Begin()
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
// Run migration
_, err = tx.Exec(migration.Up)
if err != nil {
tx.Rollback()
return fmt.Errorf("failed to apply migration %d: %w", migration.Version, err)
}
// Record migration
_, err = tx.Exec(
"INSERT INTO schema_migrations (version) VALUES (?)",
migration.Version)
if err != nil {
tx.Rollback()
return fmt.Errorf("failed to record migration %d: %w", migration.Version, err)
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("failed to commit migration %d: %w", migration.Version, err)
}
log.Printf("Applied migration %d", migration.Version)
}
}
log.Println("Database migrations completed successfully")
return nil
}
func prepareStatements() error {
var err error
// Prepare balance query
stmtGetBalance, err = DBClient.Prepare(`
SELECT gp.currency_balance
FROM guild_profiles gp
JOIN users u ON gp.user_id = u.id
WHERE u.discord_id = ? AND gp.guild_id = ?`)
if err != nil {
return fmt.Errorf("failed to prepare balance query: %w", err)
}
// Prepare leaderboard query
stmtGetLeaderboard, err = DBClient.Prepare(`
SELECT u.username, gp.currency_balance, gp.message_count
FROM guild_profiles gp
JOIN users u ON gp.user_id = u.id
WHERE gp.guild_id = ?
ORDER BY gp.currency_balance DESC
LIMIT ?`)
if err != nil {
return fmt.Errorf("failed to prepare leaderboard query: %w", err)
}
// Prepare user profile query
stmtGetUserProfile, err = DBClient.Prepare(`
SELECT message_count, last_reward_at
FROM guild_profiles
WHERE user_id = ? AND guild_id = ?`)
if err != nil {
return fmt.Errorf("failed to prepare user profile query: %w", err)
}
log.Println("Prepared statements initialized successfully")
return nil
}
// CleanupDB closes all prepared statements
func CleanupDB() {
if stmtGetBalance != nil {
stmtGetBalance.Close()
}
if stmtUpdateBalance != nil {
stmtUpdateBalance.Close()
}
if stmtGetLeaderboard != nil {
stmtGetLeaderboard.Close()
}
if stmtGetUserProfile != nil {
stmtGetUserProfile.Close()
}
if stmtUpdateProfile != nil {
stmtUpdateProfile.Close()
}
log.Println("Database cleanup completed")
}

View File

@@ -1,27 +1,28 @@
package lib
import (
"fmt"
"log"
"net"
"os"
"github.com/bwmarrin/discordgo"
"github.com/diamondburned/arikawa/v3/api"
"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/utils/json/option"
)
// respondWithError sends an error message as a response to the interaction
func RespondWithError(s *discordgo.Session, i *discordgo.InteractionCreate, message string) {
log.Printf("Responding with error: %s", message)
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: message,
Flags: discordgo.MessageFlagsEphemeral,
},
})
if err != nil {
log.Printf("Error sending error response: %v", err)
func ErrorResponse(err error) *api.InteractionResponseData {
var content string
switch e := err.(type) {
case *net.OpError:
content = "**Network Error:** " + e.Error()
case *os.PathError:
content = "**File Error:** " + e.Error()
default:
content = "**Error:** " + err.Error()
}
return &api.InteractionResponseData{
Content: option.NewNullableString(content),
Flags: discord.EphemeralMessage,
AllowedMentions: &api.AllowedMentions{},
}
}
func ThrowWithError(command, message string) error {
return fmt.Errorf("error in command '%s': %s", command, message)
}

89
lib/helpers.go Normal file
View File

@@ -0,0 +1,89 @@
package lib
import (
"fmt"
"os"
"strings"
"time"
"github.com/diamondburned/arikawa/v3/discord"
)
var manager = NewTimerManager()
// Userish is an interface that captures the common methods you may want to call
// on either a discord.Member or discord.User, including a display name.
type Userish interface {
ID() discord.UserID
Username() string
DisplayName() string
}
// memberUser adapts a discord.Member to the Userish interface.
type memberUser struct {
*discord.Member
}
func (mu memberUser) ID() discord.UserID {
return mu.User.ID
}
func (mu memberUser) Username() string {
return mu.User.Username
}
func (mu memberUser) DisplayName() string {
// If Nick is set, return it as the display name, otherwise return Username
if mu.Member.Nick != "" {
return mu.Member.Nick
}
return mu.User.Username
}
// directUser adapts a discord.User to the Userish interface.
type directUser struct {
*discord.User
}
func (du directUser) ID() discord.UserID {
return du.User.ID
}
func (du directUser) Username() string {
return du.User.Username
}
func (du directUser) DisplayName() string {
// For a direct user, the display name is just the username since no nickname is available.
return du.User.Username
}
// GetUserObject takes an interaction event and returns a Userish, which may be
// either a discord.Member or a discord.User, but exposes it through a consistent interface.
func GetUserObject(event discord.InteractionEvent) Userish {
if event.Member != nil {
return memberUser{event.Member}
} else {
return directUser{event.User}
}
}
func CooldownHandler(event discord.InteractionEvent, key string, duration time.Duration) (bool, string) {
user := GetUserObject(event)
allowList := strings.Split(os.Getenv("COOLDOWN_ALLOW_LIST"), ",")
// Check if the user ID is in the allowList
for _, id := range allowList {
if id == user.ID().String() {
return true, ""
}
}
isOnCooldown, remaining := manager.TimerRunning(user.ID().String(), key)
if isOnCooldown {
return false, fmt.Sprintf("You are on cooldown. Please wait for %v", remaining)
}
manager.StartTimer(user.ID().String(), key, duration)
return true, ""
}

View File

@@ -1,318 +0,0 @@
package lib
import (
"database/sql"
"fmt"
"time"
"github.com/bwmarrin/discordgo"
)
type HimbucksEntry struct {
Username string
Balance int
MessageCount int
}
func ProcessHimbucks(s *discordgo.Session, m *discordgo.MessageCreate, ctx *ProcessContext) error {
tx, err := DBClient.Begin()
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback()
// Get current state
var messageCount int
var lastRewardAt sql.NullTime
var multiplier float64
err = tx.QueryRow(`
SELECT message_count, last_reward_at, COALESCE(multiplier, 1.0)
FROM guild_profiles
WHERE user_id = ? AND guild_id = ?`,
ctx.UserID, ctx.GuildID).Scan(&messageCount, &lastRewardAt, &multiplier)
if err != nil {
return fmt.Errorf("failed to get message count: %w", err)
}
messageCount++
shouldReward := messageCount >= AppConfig.MessageCountThreshold &&
(!lastRewardAt.Valid || time.Since(lastRewardAt.Time) >= AppConfig.CooldownPeriod)
if shouldReward {
reward := int(float64(AppConfig.HimbucksPerReward) * multiplier)
_, err = tx.Exec(`
UPDATE guild_profiles
SET currency_balance = currency_balance + ?,
message_count = 0,
last_reward_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND guild_id = ?`,
reward, ctx.UserID, ctx.GuildID)
} else {
_, err = tx.Exec(`
UPDATE guild_profiles
SET message_count = ?
WHERE user_id = ? AND guild_id = ?`,
messageCount, ctx.UserID, ctx.GuildID)
}
if err != nil {
return fmt.Errorf("failed to update profile: %w", err)
}
return tx.Commit()
}
func GetBalance(discordID, guildID string) (int, error) {
var balance int
err := stmtGetBalance.QueryRow(discordID, guildID).Scan(&balance)
if err == sql.ErrNoRows {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("failed to get balance: %w", err)
}
return balance, nil
}
func SendBalance(fromDiscordID, toDiscordID, guildID string, amount int) error {
// Start database transaction
tx, err := DBClient.Begin()
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback()
// Get sender's user ID
var fromUserID string
err = tx.QueryRow(`
SELECT id
FROM users
WHERE discord_id = ?`, fromDiscordID).Scan(&fromUserID)
if err != nil {
return fmt.Errorf("sender not found: %w", err)
}
// Get recipient's user ID
var toUserID string
err = tx.QueryRow(`
SELECT id
FROM users
WHERE discord_id = ?`, toDiscordID).Scan(&toUserID)
if err != nil {
return fmt.Errorf("recipient not found: %w", err)
}
// Check if sender has sufficient balance
var senderBalance int
err = tx.QueryRow(`
SELECT currency_balance
FROM guild_profiles
WHERE user_id = ? AND guild_id = ?`,
fromUserID, guildID).Scan(&senderBalance)
if err != nil {
return fmt.Errorf("failed to get sender balance: %w", err)
}
if senderBalance < amount {
return fmt.Errorf("insufficient balance: have %d, trying to send %d", senderBalance, amount)
}
// Deduct from sender
_, err = tx.Exec(`
UPDATE guild_profiles
SET currency_balance = currency_balance - ?
WHERE user_id = ? AND guild_id = ?`,
amount, fromUserID, guildID)
if err != nil {
return fmt.Errorf("failed to deduct from sender: %w", err)
}
// Add to recipient
result, err := tx.Exec(`
UPDATE guild_profiles
SET currency_balance = currency_balance + ?
WHERE user_id = ? AND guild_id = ?`,
amount, toUserID, guildID)
if err != nil {
return fmt.Errorf("failed to add to recipient: %w", err)
}
// Check if recipient exists in guild_profiles
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to check rows affected: %w", err)
}
// If recipient doesn't have a profile in this guild, create one
if rowsAffected == 0 {
_, err = tx.Exec(`
INSERT INTO guild_profiles (user_id, guild_id, currency_balance, message_count)
VALUES (?, ?, ?, 0)`,
toUserID, guildID, amount)
if err != nil {
return fmt.Errorf("failed to create recipient profile: %w", err)
}
}
// Commit transaction
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
func GetLeaderboard(guildID string, limit int) ([]HimbucksEntry, error) {
rows, err := stmtGetLeaderboard.Query(guildID, limit)
if err != nil {
return nil, fmt.Errorf("failed to get leaderboard: %w", err)
}
defer rows.Close()
var entries []HimbucksEntry
for rows.Next() {
var entry HimbucksEntry
err := rows.Scan(&entry.Username, &entry.Balance, &entry.MessageCount)
if err != nil {
return nil, fmt.Errorf("failed to scan leaderboard entry: %w", err)
}
entries = append(entries, entry)
}
return entries, nil
}
func BuyMultiplier(discordID, guildID string) (float64, int, int, error) {
tx, err := DBClient.Begin()
if err != nil {
return 0, 0, 0, fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback()
var userID int
err = tx.QueryRow("SELECT id FROM users WHERE discord_id = ?", discordID).Scan(&userID)
if err != nil {
return 0, 0, 0, fmt.Errorf("user not found: %w", err)
}
var balance int
var currentMultiplier float64
err = tx.QueryRow(`
SELECT currency_balance, COALESCE(multiplier, 1.0)
FROM guild_profiles
WHERE user_id = ? AND guild_id = ?`,
userID, guildID).Scan(&balance, &currentMultiplier)
if err != nil {
return 0, 0, 0, fmt.Errorf("failed to get profile: %w", err)
}
cost := int(1000 * currentMultiplier)
if balance < cost {
return 0, 0, 0, fmt.Errorf("insufficient funds: have %d, need %d", balance, cost)
}
newBalance := balance - cost
newMultiplier := currentMultiplier + 0.5
_, err = tx.Exec(`
UPDATE guild_profiles
SET currency_balance = ?,
multiplier = ?
WHERE user_id = ? AND guild_id = ?`,
newBalance, newMultiplier, userID, guildID)
if err != nil {
return 0, 0, 0, fmt.Errorf("failed to update profile: %w", err)
}
if err = tx.Commit(); err != nil {
return 0, 0, 0, fmt.Errorf("failed to commit: %w", err)
}
return newMultiplier, cost, newBalance, nil
}
func BuyPizza(discordID, guildID string) (int, int, error) {
tx, err := DBClient.Begin()
if err != nil {
return 0, 0, fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback()
var userID int
err = tx.QueryRow("SELECT id FROM users WHERE discord_id = ?", discordID).Scan(&userID)
if err != nil {
return 0, 0, fmt.Errorf("user not found: %w", err)
}
var balance int
err = tx.QueryRow(`
SELECT currency_balance
FROM guild_profiles
WHERE user_id = ? AND guild_id = ?`,
userID, guildID).Scan(&balance)
if err != nil {
return 0, 0, fmt.Errorf("failed to get profile: %w", err)
}
cost := 100
if balance < cost {
return 0, 0, fmt.Errorf("insufficient funds: have %d, need %d", balance, cost)
}
newBalance := balance - cost
_, err = tx.Exec(`
UPDATE guild_profiles
SET currency_balance = ?
WHERE user_id = ? AND guild_id = ?`,
newBalance, userID, guildID)
if err != nil {
return 0, 0, fmt.Errorf("failed to update balance: %w", err)
}
if err = tx.Commit(); err != nil {
return 0, 0, fmt.Errorf("failed to commit transaction: %w", err)
}
return cost, newBalance, nil
}
func GiveHimbucks(discordID, guildID string, amount int) error {
tx, err := DBClient.Begin()
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback()
var userID int
// Get user ID
err = tx.QueryRow("SELECT id FROM users WHERE discord_id = ?", discordID).Scan(&userID)
if err != nil {
return fmt.Errorf("user not found: %w", err)
}
result, err := tx.Exec(`
UPDATE guild_profiles
SET currency_balance = currency_balance + ?
WHERE user_id = ? AND guild_id = ?`,
amount, userID, guildID)
if err != nil {
return fmt.Errorf("failed to update balance: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to check rows affected: %w", err)
}
if rows == 0 {
_, err = tx.Exec(`
INSERT INTO guild_profiles (user_id, guild_id, currency_balance, message_count)
VALUES (?, ?, ?, 0)`,
userID, guildID, amount)
if err != nil {
return fmt.Errorf("failed to create profile: %w", err)
}
}
return tx.Commit()
}

View File

@@ -1,262 +0,0 @@
package lib
import (
"math/rand"
"regexp"
"strings"
"time"
)
type MarkovData struct {
Order int
Chain map[string][]string // "word1 ... wordN" -> ["word3", ...]
Starts []string
}
var (
urlRegex = regexp.MustCompile(`https?://[^\s]+`)
mentionRegex = regexp.MustCompile(`<[@#&!][^>]+>`)
bracketRegex = regexp.MustCompile(`\[.*?\]`)
speakerRegex = regexp.MustCompile(`^(?:[A-Z]{2,}\s+)+`)
stopWords = map[string]bool{
"the": true, "and": true, "a": true, "to": true, "of": true,
"in": true, "is": true, "that": true, "it": true, "for": true,
"as": true, "with": true, "on": true, "at": true, "by": true,
"this": true, "from": true, "but": true, "or": true, "an": true,
"be": true, "are": true, "was": true, "were": true, "so": true,
"if": true, "out": true, "up": true, "about": true, "into": true,
"over": true, "after": true, "beneath": true, "under": true,
"above": true, "me": true, "my": true, "mine": true, "you": true,
"your": true, "yours": true, "he": true, "him": true, "his": true,
"she": true, "her": true, "hers": true, "they": true, "them": true,
"their": true, "theirs": true, "we": true, "us": true, "our": true,
"ours": true, "who": true, "whom": true, "whose": true, "what": true,
"which": true, "when": true, "where": true, "why": true, "how": true,
"give": true, "write": true, "tell": true, "say": true, "speak": true,
"make": true, "do": true, "does": true, "did": true, "done": true,
}
)
func init() {
rand.Seed(time.Now().UnixNano())
}
func CleanText(text string) string {
text = urlRegex.ReplaceAllString(text, "")
text = mentionRegex.ReplaceAllString(text, "")
text = bracketRegex.ReplaceAllString(text, "")
text = strings.TrimSpace(text)
text = speakerRegex.ReplaceAllString(text, "")
return strings.Join(strings.Fields(text), " ")
}
func BuildMarkovChain(lines []string, order int) *MarkovData {
data := &MarkovData{
Order: order,
Chain: make(map[string][]string),
Starts: make([]string, 0),
}
var allWords []string
for _, line := range lines {
// Skip likely headers/metadata (all caps lines)
trimmed := strings.TrimSpace(line)
if trimmed != "" && strings.ToUpper(trimmed) == trimmed && strings.ToLower(trimmed) != trimmed {
continue
}
cleaned := CleanText(line)
if cleaned == "" {
continue
}
allWords = append(allWords, strings.Fields(cleaned)...)
}
if len(allWords) < order+1 {
return data
}
// First key is always a start
data.Starts = append(data.Starts, Key(allWords[:order]...))
for i := 0; i < len(allWords)-order; i++ {
keyWords := allWords[i : i+order]
nextWord := allWords[i+order]
k := Key(keyWords...)
data.Chain[k] = append(data.Chain[k], nextWord)
// If the word shifting out ends a sentence, the next sequence is a start
if strings.ContainsAny(allWords[i], ".!?") {
if i+1+order <= len(allWords) {
data.Starts = append(data.Starts, Key(allWords[i+1:i+1+order]...))
}
}
}
return data
}
func GenerateMessage(data *MarkovData, seed string) string {
if len(data.Starts) == 0 {
return ""
}
var currentKey string
// Try to seed based on input question
if seed != "" {
seedWords := strings.Fields(CleanText(seed))
// Sort seed words: significant words first, then by length
for i := 0; i < len(seedWords); i++ {
for j := i + 1; j < len(seedWords); j++ {
sw1 := strings.ToLower(seedWords[i])
sw2 := strings.ToLower(seedWords[j])
isStop1 := stopWords[sw1]
isStop2 := stopWords[sw2]
// If one is a stop word and the other isn't, prioritize the non-stop word
if isStop1 && !isStop2 {
seedWords[i], seedWords[j] = seedWords[j], seedWords[i]
} else if !isStop1 && isStop2 {
continue
} else {
// Otherwise sort by length
if len(seedWords[i]) < len(seedWords[j]) {
seedWords[i], seedWords[j] = seedWords[j], seedWords[i]
}
}
}
}
var candidates []string
// 1. Try to find a sentence starter
// We iterate seed words first to prioritize matches for longer words
for _, sw := range seedWords {
if len(sw) <= 2 {
continue
}
swLower := strings.ToLower(sw)
var primaryMatches []string // starts with word
for _, startKey := range data.Starts {
parts := strings.Fields(strings.ToLower(startKey))
if len(parts) < data.Order {
continue
}
if parts[0] == swLower {
primaryMatches = append(primaryMatches, startKey)
}
}
// If we found sentence starters beginning with this word, use them exclusively
if len(primaryMatches) > 0 {
candidates = primaryMatches
break
}
}
// 2. If no perfect starts, try any start containing the word
if len(candidates) == 0 {
for _, sw := range seedWords {
if len(sw) <= 2 {
continue
}
swLower := strings.ToLower(sw)
for _, startKey := range data.Starts {
parts := strings.Fields(strings.ToLower(startKey))
if len(parts) < data.Order {
continue
}
// Check remaining words in key
found := false
for i := 1; i < len(parts); i++ {
if parts[i] == swLower {
found = true
break
}
}
if found {
candidates = append(candidates, startKey)
}
}
if len(candidates) > 0 {
break
}
}
}
// 3. If no starts, try to find any connection in the chain
if len(candidates) == 0 {
for _, sw := range seedWords {
if len(sw) <= 2 {
continue
}
swLower := strings.ToLower(sw)
var matches []string
for k := range data.Chain {
parts := strings.Fields(strings.ToLower(k))
if len(parts) < data.Order {
continue
}
if parts[0] == swLower {
matches = append(matches, k)
}
}
if len(matches) > 0 {
candidates = matches
break
}
}
}
if len(candidates) > 0 {
currentKey = candidates[rand.Intn(len(candidates))]
}
}
if currentKey == "" {
currentKey = data.Starts[rand.Intn(len(data.Starts))]
}
output := strings.Fields(currentKey)
for i := 0; i < 40; i++ {
nextOptions, exists := data.Chain[currentKey]
if !exists || len(nextOptions) == 0 {
break
}
nextWord := nextOptions[rand.Intn(len(nextOptions))]
output = append(output, nextWord)
// Shift the key window
currentWords := strings.Fields(currentKey)
if len(currentWords) >= 1 {
newKeyWords := append(currentWords[1:], nextWord)
currentKey = Key(newKeyWords...)
} else {
break
}
// Soft stop on punctuation
if i > 5 && strings.ContainsAny(nextWord, ".!?") {
if rand.Float32() > 0.3 {
break
}
}
}
return strings.Join(output, " ")
}
func Key(words ...string) string {
return strings.Join(words, " ")
}

View File

@@ -1,47 +0,0 @@
package lib
import (
"github.com/bwmarrin/discordgo"
)
// InteractionUser represents a user from an interaction, abstracting away the differences
// between guild members and DM users.
type InteractionUser struct {
ID string
Username string
Bot bool
}
// GetUser extracts user information from an interaction, handling both guild and DM cases.
func GetUser(i *discordgo.InteractionCreate) (*InteractionUser, error) {
if i.Member != nil && i.Member.User != nil {
// Guild interaction
return &InteractionUser{
ID: i.Member.User.ID,
Username: i.Member.User.Username,
Bot: i.Member.User.Bot,
}, nil
} else if i.User != nil {
// DM interaction
return &InteractionUser{
ID: i.User.ID,
Username: i.User.Username,
Bot: i.User.Bot,
}, nil
}
return nil, ThrowWithError("GetUser", "Unable to extract user information from interaction")
}
// IsInGuild checks if the interaction occurred in a guild.
func IsInGuild(i *discordgo.InteractionCreate) bool {
return i.Member != nil
}
// GetGuildID safely retrieves the guild ID if the interaction is from a guild.
func GetGuildID(i *discordgo.InteractionCreate) string {
if i.GuildID != "" {
return i.GuildID
}
return ""
}

View File

@@ -1,63 +0,0 @@
package lib
import (
"fmt"
"github.com/bwmarrin/discordgo"
)
// Represents a function that processes a message
type MessageProcessor func(s *discordgo.Session, m *discordgo.MessageCreate, ctx *ProcessContext) error
// Holds shared data between processors
type ProcessContext struct {
UserID int
GuildID string
DiscordUser *discordgo.User
}
// Handles the registration and execution of message processors
type MessageProcessorManager struct {
processors []MessageProcessor
}
// Creates a new MessageProcessorManager
func NewMessageProcessorManager() *MessageProcessorManager {
return &MessageProcessorManager{
processors: make([]MessageProcessor, 0),
}
}
// Adds a new message processor to the manager
func (pm *MessageProcessorManager) RegisterProcessor(processor MessageProcessor) {
pm.processors = append(pm.processors, processor)
}
// Runs all registered processors on a message
func (pm *MessageProcessorManager) ProcessMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author.Bot {
return
}
// Create processing context
ctx := &ProcessContext{
GuildID: m.GuildID,
DiscordUser: m.Author,
}
// Get or create user and guild profile together
userID, err := GetOrCreateUserWithGuild(m.Author.ID, m.Author.Username, m.GuildID)
if err != nil {
ThrowWithError("MessageProcessor", fmt.Sprintf("failed to handle user and guild profile: %v", err))
return
}
ctx.UserID = userID
// Run each processor with the context
for _, processor := range pm.processors {
if err := processor(s, m, ctx); err != nil {
ThrowWithError("MessageProcessor", err.Error())
}
}
}

143
lib/replicate.go Normal file
View File

@@ -0,0 +1,143 @@
package lib
import (
"bytes"
"context"
"errors"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/replicate/replicate-go"
)
var ReplicatePromptPrefix = "Your designation is Himbot. You are an assistant bot designed to provide helpful responses with a touch of wit and sarcasm. Your responses should be natural and engaging, reflecting your unique personality. Avoid clichéd or overused expressions of sarcasm. Instead, focus on delivering information in a clever and subtly humorous way. If a question does not make any sense, or is not factually coherent, explain why instead of answering something not correct. If you don't know the answer to a question, please don't share false information."
func ReplicateTextGeneration(prompt string) (string, error) {
client, clientError := replicate.NewClient(replicate.WithTokenFromEnv())
if clientError != nil {
return "", clientError
}
input := replicate.PredictionInput{
"prompt": prompt,
"system_prompt": ReplicatePromptPrefix,
"max_new_tokens": 4096,
}
webhook := replicate.Webhook{
URL: "https://example.com/webhook",
Events: []replicate.WebhookEventType{"start", "completed"},
}
prediction, predictionError := client.Run(context.Background(), "meta/llama-2-70b-chat:2d19859030ff705a87c746f7e96eea03aefb71f166725aee39692f1476566d48", input, &webhook)
if predictionError != nil {
return "", predictionError
}
if prediction == nil {
return "", errors.New("there was an error generating a response based on this prompt... please reach out to @himbothyswaggins to fix this issue")
}
test, ok := prediction.([]interface{})
if !ok {
return "", errors.New("there was an error generating a response based on this prompt... please reach out to @himbothyswaggins to fix this issue")
}
strs := make([]string, len(test))
for i, v := range test {
str, ok := v.(string)
if !ok {
return "", errors.New("element is not a string")
}
strs[i] = str
}
result := strings.Join(strs, "")
return result, nil
}
func ReplicateImageGeneration(prompt string, filename string) (*bytes.Buffer, error) {
client, clientError := replicate.NewClient(replicate.WithTokenFromEnv())
if clientError != nil {
return nil, clientError
}
input := replicate.PredictionInput{
"width": 1024,
"height": 1024,
"prompt": prompt,
"refine": "expert_ensemble_refiner",
"negative_prompt": "worst quality, normal quality, low quality, low res, blurry, text, watermark, logo, banner, extra digits, cropped, jpeg artifacts, signature, username, error, sketch ,duplicate, ugly, monochrome, horror, geometry, mutation, disgusting",
"num_outputs": 1,
"disable_safety_checker": true,
}
webhook := replicate.Webhook{
URL: "https://example.com/webhook",
Events: []replicate.WebhookEventType{"start", "completed"},
}
prediction, predictionError := client.Run(context.Background(), "stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b", input, &webhook)
if predictionError != nil {
return nil, predictionError
}
if prediction == nil {
return nil, errors.New("there was an error generating the image based on this prompt... please reach out to @himbothyswaggins to fix this issue")
}
test, ok := prediction.([]interface{})
if !ok {
return nil, errors.New("there was an error generating the image based on this prompt... please reach out to @himbothyswaggins to fix this issue")
}
imgUrl, ok := test[0].(string)
if !ok {
return nil, errors.New("there was an error generating the image based on this prompt... please reach out to @himbothyswaggins to fix this issue")
}
imageRes, imageGetErr := http.Get(imgUrl)
if imageGetErr != nil {
return nil, imageGetErr
}
defer imageRes.Body.Close()
imageBytes, imgReadErr := io.ReadAll(imageRes.Body)
if imgReadErr != nil {
return nil, imgReadErr
}
// Save image to a temporary file
tmpfile, err := os.Create(filename)
if err != nil {
log.Fatal(err)
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.Write(imageBytes); err != nil {
log.Fatal(err)
}
if err := tmpfile.Close(); err != nil {
log.Fatal(err)
}
// Upload the image to S3
_, uploadErr := UploadToS3(tmpfile.Name())
if uploadErr != nil {
log.Printf("Failed to upload image to S3: %v", uploadErr)
}
imageFile := bytes.NewBuffer(imageBytes)
return imageFile, nil
}

55
lib/s3.go Normal file
View File

@@ -0,0 +1,55 @@
package lib
import (
"fmt"
"os"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
)
func UploadToS3(filePath string) (*s3manager.UploadOutput, error) {
bucket := os.Getenv("BUCKET_NAME")
if bucket == "" {
fmt.Println("No S3 bucket specified, skipping upload.")
return nil, nil
}
endpoint := os.Getenv("AWS_ENDPOINT_URL_S3")
accessKeyID := os.Getenv("AWS_ACCESS_KEY_ID")
secretAccessKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
region := os.Getenv("AWS_REGION")
sess, err := session.NewSession(&aws.Config{
Region: &region,
Credentials: credentials.NewStaticCredentials(
accessKeyID,
secretAccessKey,
"",
),
Endpoint: aws.String(endpoint),
})
if err != nil {
return nil, fmt.Errorf("failed to create session, %v", err)
}
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file, %v", err)
}
defer file.Close()
uploader := s3manager.NewUploader(sess)
result, err := uploader.Upload(&s3manager.UploadInput{
Bucket: aws.String(bucket),
Key: aws.String(filePath),
Body: file,
})
if err != nil {
return nil, fmt.Errorf("failed to upload file, %v", err)
}
return result, nil
}

72
lib/timer.go Normal file
View File

@@ -0,0 +1,72 @@
package lib
import (
"sync"
"time"
)
var (
mu sync.Mutex
instance *TimerManager
)
type TimerManager struct {
timers map[string]time.Time
mu sync.Mutex
}
func NewTimerManager() *TimerManager {
return &TimerManager{
timers: make(map[string]time.Time),
}
}
func GetInstance() *TimerManager {
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = &TimerManager{
timers: make(map[string]time.Time),
}
}
return instance
}
func (m *TimerManager) StartTimer(userID string, key string, duration time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
m.timers[userID+":"+key] = time.Now().Add(duration)
}
func (m *TimerManager) TimerRunning(userID string, key string) (bool, time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
timerEnd, exists := m.timers[userID+":"+key]
if !exists {
return false, 0
}
if time.Now().After(timerEnd) {
delete(m.timers, userID+":"+key)
return false, 0
}
return true, time.Until(timerEnd)
}
func CancelTimer(userID string, key string) {
manager := GetInstance()
// Handle non-existent keys gracefully
if _, exists := manager.timers[userID+":"+key]; !exists {
return
}
manager.mu.Lock()
defer manager.mu.Unlock()
delete(manager.timers, userID+":"+key)
}

View File

@@ -1,43 +0,0 @@
package lib
import "fmt"
func GetOrCreateUserWithGuild(discordID string, username string, guildID string) (int, error) {
var userID int
tx, err := DBClient.Begin()
if err != nil {
return 0, fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback()
// First get or create the user
err = tx.QueryRow(`
INSERT INTO users (discord_id, username)
VALUES (?, ?)
ON CONFLICT (discord_id)
DO UPDATE SET username = excluded.username
RETURNING id`,
discordID, username).Scan(&userID)
if err != nil {
return 0, fmt.Errorf("failed to get or create user: %w", err)
}
// Then ensure guild profile exists for this user
_, err = tx.Exec(`
INSERT INTO guild_profiles (user_id, guild_id, currency_balance, message_count)
VALUES (?, ?, 0, 0)
ON CONFLICT (user_id, guild_id) DO NOTHING`,
userID, guildID)
if err != nil {
return 0, fmt.Errorf("failed to create guild profile: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("failed to commit transaction: %w", err)
}
return userID, nil
}

369
main.go
View File

@@ -1,317 +1,106 @@
package main
import (
"fmt"
"context"
"himbot/command"
"himbot/lib"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/bwmarrin/discordgo"
"github.com/diamondburned/arikawa/v3/api"
"github.com/diamondburned/arikawa/v3/api/cmdroute"
"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/gateway"
"github.com/diamondburned/arikawa/v3/state"
"github.com/joho/godotenv"
)
var (
commands []*discordgo.ApplicationCommand
commandHandlers map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate)
)
func main() {
godotenv.Load(".env")
// Load configuration
config := lib.LoadConfig()
// Initialize commands and handlers with config
initCommands(config)
initCommandHandlers(config)
if err := command.InitBard("datasets/bard.gob"); err != nil {
log.Printf("Failed to load Bard dataset: %v", err)
}
err := lib.InitDB()
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
if config.DiscordToken == "" {
log.Fatalln("No $DISCORD_TOKEN given.")
}
dg, err := discordgo.New("Bot " + config.DiscordToken)
if err != nil {
log.Fatalf("Error creating Discord session: %v", err)
}
dg.AddHandler(ready)
dg.AddHandler(interactionCreate)
processorManager := lib.NewMessageProcessorManager()
// Register processors
processorManager.RegisterProcessor(lib.ProcessHimbucks)
dg.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
processorManager.ProcessMessage(s, m)
})
dg.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages
err = dg.Open()
if err != nil {
log.Fatalf("Error opening connection: %v", err)
}
log.Println("Bot is now running. Press CTRL-C to exit.")
registerCommands(dg)
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
<-sc
log.Println("Shutting down gracefully...")
if lib.DBClient != nil {
// Close prepared statements
lib.CleanupDB()
lib.DBClient.Close()
}
dg.Close()
}
func ready(s *discordgo.Session, event *discordgo.Ready) {
log.Printf("Logged in as: %v#%v", s.State.User.Username, s.State.User.Discriminator)
}
func interactionCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok {
h(s, i)
}
}
func registerCommands(s *discordgo.Session) {
log.Println("Checking command registration...")
existingCommands, err := s.ApplicationCommands(s.State.User.ID, "")
if err != nil {
log.Printf("Error fetching existing commands: %v", err)
return
}
// Create maps for easier comparison
existingMap := make(map[string]*discordgo.ApplicationCommand)
for _, cmd := range existingCommands {
existingMap[cmd.Name] = cmd
}
desiredMap := make(map[string]*discordgo.ApplicationCommand)
for _, cmd := range commands {
desiredMap[cmd.Name] = cmd
}
// Delete commands that no longer exist
for name, existingCmd := range existingMap {
if _, exists := desiredMap[name]; !exists {
log.Printf("Deleting removed command: %s", name)
err := s.ApplicationCommandDelete(s.State.User.ID, "", existingCmd.ID)
if err != nil {
log.Printf("Error deleting command %s: %v", name, err)
}
}
}
// Update or create commands
for _, desiredCmd := range commands {
if existingCmd, exists := existingMap[desiredCmd.Name]; exists {
// Check if command needs updating (simple comparison)
if !commandsEqual(existingCmd, desiredCmd) {
log.Printf("Updating command: %s", desiredCmd.Name)
_, err := s.ApplicationCommandEdit(s.State.User.ID, "", existingCmd.ID, desiredCmd)
if err != nil {
log.Printf("Error updating command %s: %v", desiredCmd.Name, err)
}
} else {
log.Printf("Command %s is up to date", desiredCmd.Name)
}
} else {
log.Printf("Creating new command: %s", desiredCmd.Name)
_, err := s.ApplicationCommandCreate(s.State.User.ID, "", desiredCmd)
if err != nil {
log.Printf("Error creating command %s: %v", desiredCmd.Name, err)
}
}
}
log.Println("Command registration completed")
}
// commandsEqual performs a basic comparison between two commands
func commandsEqual(existing, desired *discordgo.ApplicationCommand) bool {
if existing.Name != desired.Name ||
existing.Description != desired.Description ||
len(existing.Options) != len(desired.Options) {
return false
}
// Compare options (basic comparison)
for i, existingOpt := range existing.Options {
if i >= len(desired.Options) {
return false
}
desiredOpt := desired.Options[i]
if existingOpt.Name != desiredOpt.Name ||
existingOpt.Description != desiredOpt.Description ||
existingOpt.Type != desiredOpt.Type ||
existingOpt.Required != desiredOpt.Required {
return false
}
}
return true
}
// initCommands initializes command definitions with configuration
func initCommands(config *lib.Config) {
commands = []*discordgo.ApplicationCommand{
var commands = []api.CreateCommandData{
{
Name: "ping",
Description: "ping pong!",
},
{
Name: "ask",
Description: "Ask Himbot! Cooldown: 1 Minute.",
Options: []discord.CommandOption{
&discord.StringOption{
OptionName: "prompt",
Description: "The prompt to send to Himbot.",
Required: true,
},
},
},
{
Name: "pic",
Description: "Generate an image! Cooldown: 5 Minutes.",
Options: []discord.CommandOption{
&discord.StringOption{
OptionName: "prompt",
Description: "The prompt for the image generation.",
Required: true,
},
},
},
{
Name: "hs",
Description: "This command was your nickname in highschool!",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "nickname",
Options: []discord.CommandOption{
&discord.StringOption{
OptionName: "nickname",
Description: "Your nickname in highschool.",
Required: true,
},
},
},
{
Name: "bard",
Description: "Ask the bard a question",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "question",
Description: "The question you want to ask",
Required: false,
},
},
},
{
Name: "ask",
Description: "Ask a question and get a markov chain answer based on channel contents (like ChatGPT but dumb!)",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "question",
Description: "The question you want to ask",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "messages",
Description: fmt.Sprintf("Number of messages to use (default: %d, max: %d)", config.MarkovDefaultMessages, config.MarkovMaxMessages),
Required: false,
},
},
},
{
Name: "himbucks",
Description: "Check your himbucks balance",
},
{
Name: "himboard",
Description: "View the himbucks leaderboard",
},
{
Name: "sendbucks",
Description: "Send himbucks to another user",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "The user to send himbucks to",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "amount",
Description: "Amount of himbucks to send",
Required: true,
MinValue: &[]float64{1}[0],
},
},
},
{
Name: "shop",
Description: "Spend your himbucks on cool stuff",
Options: []*discordgo.ApplicationCommandOption{
{
Name: "buy",
Description: "Buy an item",
Type: discordgo.ApplicationCommandOptionSubCommand,
Options: []*discordgo.ApplicationCommandOption{
{
Name: "item",
Description: "The item to buy",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
Choices: []*discordgo.ApplicationCommandOptionChoice{
{
Name: "pizza",
Value: "pizza",
},
{
Name: "multiplier",
Value: "multiplier",
},
},
},
},
},
},
},
{
Name: "givebucks",
Description: "Admin: Give himbucks to a user",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "The user to give himbucks to",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "amount",
Description: "Amount of himbucks to give (negative to take)",
Required: true,
},
},
},
}
func main() {
token := os.Getenv("DISCORD_TOKEN")
if token == "" {
godotenv.Load(".env")
if token == "" {
log.Fatalln("No $DISCORD_TOKEN given.")
}
}
h := newHandler(state.New("Bot " + token))
h.s.AddInteractionHandler(h)
h.s.AddIntents(gateway.IntentGuilds)
h.s.AddHandler(func(*gateway.ReadyEvent) {
me, _ := h.s.Me()
log.Println("connected to the gateway as", me.Tag())
})
if err := cmdroute.OverwriteCommands(h.s, commands); err != nil {
log.Fatalln("cannot update commands:", err)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := h.s.Connect(ctx); err != nil {
log.Fatalln("cannot connect:", err)
}
}
// initCommandHandlers initializes command handlers with configuration
func initCommandHandlers(config *lib.Config) {
commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
"ping": lib.HandleCommand("ping", time.Duration(config.PingCooldown)*time.Second, command.PingCommand),
"hs": lib.HandleCommand("hs", time.Duration(config.HsCooldown)*time.Second, command.HsCommand),
"bard": lib.HandleCommand("bard", time.Duration(config.MarkovCooldown)*time.Second, command.BardCommand),
"ask": lib.HandleCommand("ask", time.Duration(config.MarkovAskCooldown)*time.Second, command.MarkovQuestionCommand),
"himbucks": lib.HandleCommand("himbucks", time.Duration(config.HimbucksCooldown)*time.Second, command.BalanceGetCommand),
"himboard": lib.HandleCommand("himboard", time.Duration(config.HimboardCooldown)*time.Second, command.LeaderboardCommand),
"sendbucks": lib.HandleCommand("sendbucks", time.Duration(config.SendbucksCooldown)*time.Second, command.BalanceSendCommand),
"shop": lib.HandleCommand("shop", time.Duration(config.ShopCooldown)*time.Second, command.ShopCommand),
"givebucks": lib.HandleCommand("givebucks", time.Duration(config.GivebucksCooldown)*time.Second, command.GiveCommand),
}
type handler struct {
*cmdroute.Router
s *state.State
}
func newHandler(s *state.State) *handler {
h := &handler{s: s}
h.Router = cmdroute.NewRouter()
// Automatically defer handles if they're slow.
h.Use(cmdroute.Deferrable(s, cmdroute.DeferOpts{}))
h.AddFunc("ping", command.Ping)
h.AddFunc("ask", command.Ask)
h.AddFunc("pic", command.Pic)
h.AddFunc("hs", command.HS)
return h
}

View File

@@ -1 +0,0 @@
DROP TABLE IF EXISTS users;

View File

@@ -1,6 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
discord_id TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1 +0,0 @@
DROP TABLE IF EXISTS schema_migrations;

View File

@@ -1,4 +0,0 @@
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1 +0,0 @@
DROP TABLE IF EXISTS guild_profiles;

View File

@@ -1,14 +0,0 @@
CREATE TABLE IF NOT EXISTS guild_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
guild_id TEXT NOT NULL,
-- Himbucks-related fields
currency_balance INTEGER DEFAULT 0,
message_count INTEGER DEFAULT 0,
last_reward_at DATETIME,
-- Add other profile-related fields here as needed
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, guild_id)
);

View File

@@ -1 +0,0 @@
ALTER TABLE guild_profiles DROP COLUMN multiplier;

View File

@@ -1 +0,0 @@
ALTER TABLE guild_profiles ADD COLUMN multiplier REAL DEFAULT 1.0;