Compare commits
1 Commits
main
...
atridadl-p
| Author | SHA1 | Date | |
|---|---|---|---|
| be0a944665 |
@@ -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
|
||||
41
.env.example
41
.env.example
@@ -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=
|
||||
35
.github/workflows/deploy.yml
vendored
35
.github/workflows/deploy.yml
vendored
@@ -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
8
.gitignore
vendored
@@ -6,11 +6,3 @@
|
||||
.env.production.local
|
||||
.env
|
||||
himbot
|
||||
*.db
|
||||
*.db-client_wal_index
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# nix
|
||||
.direnv/
|
||||
result
|
||||
|
||||
33
Dockerfile
33
Dockerfile
@@ -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" ]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
64
command/ask.go
Normal 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{},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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!"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
56
command/pic.go
Normal 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},
|
||||
}
|
||||
}
|
||||
@@ -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!"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
27
flake.lock
generated
@@ -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
|
||||
}
|
||||
38
flake.nix
38
flake.nix
@@ -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
15
fly.toml
Normal 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
20
go.mod
@@ -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
100
go.sum
@@ -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=
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
127
lib/config.go
127
lib/config.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
224
lib/db.go
@@ -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")
|
||||
}
|
||||
@@ -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
89
lib/helpers.go
Normal 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, ""
|
||||
}
|
||||
318
lib/himbucks.go
318
lib/himbucks.go
@@ -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, ¤tMultiplier)
|
||||
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()
|
||||
}
|
||||
262
lib/markov.go
262
lib/markov.go
@@ -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, " ")
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
143
lib/replicate.go
Normal 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
55
lib/s3.go
Normal 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: ®ion,
|
||||
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
72
lib/timer.go
Normal 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)
|
||||
}
|
||||
43
lib/users.go
43
lib/users.go
@@ -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
369
main.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS users;
|
||||
@@ -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
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS schema_migrations;
|
||||
@@ -1,4 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS guild_profiles;
|
||||
@@ -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)
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE guild_profiles DROP COLUMN multiplier;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE guild_profiles ADD COLUMN multiplier REAL DEFAULT 1.0;
|
||||
Reference in New Issue
Block a user