Compare commits
35 Commits
atridadl-p
...
main
Author | SHA1 | Date | |
---|---|---|---|
6955421fd7
|
|||
b306143db2
|
|||
0a730be6f7
|
|||
3af77b6bea
|
|||
025c829c68
|
|||
a3e406fb2f
|
|||
0a07bc3dbc
|
|||
d7edcad552
|
|||
33291b478f
|
|||
4cf9c3295f
|
|||
ba9bf88a1c
|
|||
1403a756f7
|
|||
7249e08f84
|
|||
b0e9372cd9
|
|||
041914c405
|
|||
be4f67218d
|
|||
1c46b28752
|
|||
997d2cb911
|
|||
5a1158048d
|
|||
82c62658bb
|
|||
69b76bd792
|
|||
c3e62155e2
|
|||
ce580e77fd
|
|||
46c24134d5
|
|||
0194c3d4fe
|
|||
d1e2d9bf4f
|
|||
fc8be94ee9
|
|||
e14051569c
|
|||
022686e14f
|
|||
9a1b3723bc
|
|||
6a55017624
|
|||
359520fe83
|
|||
b805f27d0e
|
|||
af3f3ab355
|
|||
2e05f946c5 |
31
.env.example
31
.env.example
@ -1,2 +1,31 @@
|
||||
# Tokens
|
||||
# Discord Configuration
|
||||
DISCORD_TOKEN=""
|
||||
|
||||
# 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
|
35
.github/workflows/deploy.yml
vendored
Normal file
35
.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
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
|
18
.github/workflows/fly-deploy.yml
vendored
18
.github/workflows/fly-deploy.yml
vendored
@ -1,18 +0,0 @@
|
||||
# See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/
|
||||
|
||||
name: Fly Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy app
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: deploy-group # optional: ensure only one action runs at a time
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
- run: flyctl deploy --remote-only
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -5,4 +5,8 @@
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env
|
||||
himbot
|
||||
himbot
|
||||
*.db
|
||||
*.db-client_wal_index
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
24
Dockerfile
24
Dockerfile
@ -1,4 +1,5 @@
|
||||
FROM golang:1.23.2-alpine AS build
|
||||
# Build stage
|
||||
FROM golang:1.24.3 AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@ -8,13 +9,24 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /go/bin/app
|
||||
RUN go build -ldflags="-s -w" -o /go/bin/app
|
||||
|
||||
FROM gcr.io/distroless/static-debian11
|
||||
# Final stage
|
||||
FROM ubuntu:22.04
|
||||
|
||||
COPY --from=alpine:latest /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
# 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 /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
|
||||
|
||||
# Set the entrypoint
|
||||
ENTRYPOINT ["/app"]
|
||||
ENTRYPOINT ["/app/himbot"]
|
||||
|
89
command/himbucks.go
Normal file
89
command/himbucks.go
Normal file
@ -0,0 +1,89 @@
|
||||
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
|
||||
}
|
1424
command/markov.go
1424
command/markov.go
File diff suppressed because it is too large
Load Diff
@ -1,13 +0,0 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: your-app-image:latest
|
||||
command: ["/app"]
|
||||
pull_policy: build
|
||||
environment:
|
||||
- DISCORD_TOKEN=$DISCORD_TOKEN
|
||||
- COOLDOWN_ALLOW_LIST=$COOLDOWN_ALLOW_LIST
|
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
@ -0,0 +1,35 @@
|
||||
services:
|
||||
server:
|
||||
image: ${IMAGE}
|
||||
ports:
|
||||
- "3117:3000"
|
||||
environment:
|
||||
# Discord Configuration
|
||||
- DISCORD_TOKEN=${DISCORD_TOKEN}
|
||||
|
||||
# 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
|
19
fly.toml
19
fly.toml
@ -1,4 +1,4 @@
|
||||
# fly.toml app configuration file generated for himbot on 2024-10-20T22:41:20-06:00
|
||||
# fly.toml app configuration file generated for himbot on 2024-11-20T14:35:09-06:00
|
||||
#
|
||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||
#
|
||||
@ -8,10 +8,23 @@ primary_region = 'ord'
|
||||
|
||||
[build]
|
||||
|
||||
[[services]]
|
||||
[[mounts]]
|
||||
source = 'himbot_data'
|
||||
destination = '/data'
|
||||
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
protocol = "tcp"
|
||||
force_https = true
|
||||
auto_stop_machines = 'stop'
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
processes = ['app']
|
||||
|
||||
[[services]]
|
||||
protocol = 'tcp'
|
||||
internal_port = 3000
|
||||
min_machines_running = 1
|
||||
ports = []
|
||||
|
||||
[[vm]]
|
||||
size = 'shared-cpu-1x'
|
||||
|
15
go.mod
15
go.mod
@ -1,17 +1,18 @@
|
||||
module himbot
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/diamondburned/arikawa/v3 v3.4.0
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.28.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
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.38.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.28.1
|
||||
github.com/gorilla/schema v1.4.1 // indirect
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/tursodatabase/go-libsql v0.0.0-20250416102726-983f7e9acb0e
|
||||
)
|
||||
|
32
go.sum
32
go.sum
@ -1,21 +1,33 @@
|
||||
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
|
||||
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/diamondburned/arikawa/v3 v3.4.0 h1:wI3Qv8h2E2dkeddF1I35nv4T6OQ3RtA21rbghW/fnd0=
|
||||
github.com/diamondburned/arikawa/v3 v3.4.0/go.mod h1:WVkbdenUfsCCkptIlqSglF4eo2/HSXv74eCqGnOZaYY=
|
||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||
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/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/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-20250416102726-983f7e9acb0e h1:DUEcD8ukLWxIlcRWWJSuAX6IbEQln2bc7t9HOT45FFk=
|
||||
github.com/tursodatabase/go-libsql v0.0.0-20250416102726-983f7e9acb0e/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
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=
|
||||
|
@ -14,30 +14,46 @@ func HandleCommand(commandName string, cooldownDuration time.Duration, handler C
|
||||
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
|
||||
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
interactErr := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||
})
|
||||
if err != nil {
|
||||
ThrowWithError(commandName, "Error deferring response: "+err.Error())
|
||||
|
||||
if interactErr != nil {
|
||||
ThrowWithError(commandName, "Error deferring response: "+interactErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Execute the command handler
|
||||
response, err := handler(s, i)
|
||||
response, handlerErr := handler(s, i)
|
||||
|
||||
if err != nil {
|
||||
RespondWithError(s, i, "Error processing command: "+err.Error())
|
||||
if handlerErr != nil {
|
||||
RespondWithError(s, i, "Error processing command: "+handlerErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Send the follow-up message with the response
|
||||
_, err = s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{
|
||||
_, followErr := s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{
|
||||
Content: response,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
ThrowWithError(commandName, "Error sending follow-up message: "+err.Error())
|
||||
if followErr != nil {
|
||||
ThrowWithError(commandName, "Error sending follow-up message: "+followErr.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
96
lib/config.go
Normal file
96
lib/config.go
Normal file
@ -0,0 +1,96 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all configuration values
|
||||
type Config struct {
|
||||
// Discord settings
|
||||
DiscordToken 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
|
||||
}
|
||||
|
||||
var AppConfig *Config
|
||||
|
||||
// LoadConfig loads configuration from environment variables
|
||||
func LoadConfig() *Config {
|
||||
config := &Config{
|
||||
// Discord settings
|
||||
DiscordToken: getEnv("DISCORD_TOKEN", ""),
|
||||
|
||||
// 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),
|
||||
}
|
||||
|
||||
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
|
||||
}
|
224
lib/db.go
Normal file
224
lib/db.go
Normal file
@ -0,0 +1,224 @@
|
||||
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")
|
||||
}
|
181
lib/himbucks.go
Normal file
181
lib/himbucks.go
Normal file
@ -0,0 +1,181 @@
|
||||
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
|
||||
|
||||
err = tx.QueryRow(`
|
||||
SELECT message_count, last_reward_at
|
||||
FROM guild_profiles
|
||||
WHERE user_id = ? AND guild_id = ?`,
|
||||
ctx.UserID, ctx.GuildID).Scan(&messageCount, &lastRewardAt)
|
||||
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 {
|
||||
_, 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 = ?`,
|
||||
AppConfig.HimbucksPerReward, 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
|
||||
}
|
63
lib/processor.go
Normal file
63
lib/processor.go
Normal file
@ -0,0 +1,63 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
43
lib/users.go
Normal file
43
lib/users.go
Normal file
@ -0,0 +1,43 @@
|
||||
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
|
||||
}
|
279
main.go
279
main.go
@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"himbot/command"
|
||||
"himbot/lib"
|
||||
"log"
|
||||
@ -14,6 +15,164 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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{
|
||||
{
|
||||
Name: "ping",
|
||||
@ -32,78 +191,74 @@ var (
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "markov",
|
||||
Description: "Why did the Markov chain break up with its partner? Because it couldn't handle the past!",
|
||||
Name: "gen",
|
||||
Description: "Generate a random message using markov chains based on channel history",
|
||||
Options: []*discordgo.ApplicationCommandOption{
|
||||
{
|
||||
Type: discordgo.ApplicationCommandOptionInteger,
|
||||
Name: "messages",
|
||||
Description: "Number of messages to use (default: 100, max: 1000)",
|
||||
Description: fmt.Sprintf("Number of messages to use (default: %d, max: %d)", config.MarkovDefaultMessages, config.MarkovMaxMessages),
|
||||
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],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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", 5*time.Second, command.PingCommand),
|
||||
"hs": lib.HandleCommand("hs", 10*time.Second, command.HsCommand),
|
||||
"markov": lib.HandleCommand("markov", 30*time.Second, command.MarkovCommand),
|
||||
}
|
||||
)
|
||||
|
||||
func main() {
|
||||
godotenv.Load(".env")
|
||||
|
||||
token := os.Getenv("DISCORD_TOKEN")
|
||||
|
||||
if token == "" {
|
||||
log.Fatalln("No $DISCORD_TOKEN given.")
|
||||
}
|
||||
|
||||
dg, err := discordgo.New("Bot " + token)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating Discord session: %v", err)
|
||||
}
|
||||
|
||||
dg.AddHandler(ready)
|
||||
dg.AddHandler(interactionCreate)
|
||||
|
||||
dg.Identify.Intents = discordgo.IntentsGuilds
|
||||
|
||||
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
|
||||
|
||||
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("Registering commands...")
|
||||
registeredCommands := make([]*discordgo.ApplicationCommand, len(commands))
|
||||
for i, v := range commands {
|
||||
cmd, err := s.ApplicationCommandCreate(s.State.User.ID, "", v)
|
||||
if err != nil {
|
||||
log.Panicf("Cannot create '%v' command: %v", v.Name, err)
|
||||
}
|
||||
registeredCommands[i] = cmd
|
||||
"ping": lib.HandleCommand("ping", time.Duration(config.PingCooldown)*time.Second, command.PingCommand),
|
||||
"hs": lib.HandleCommand("hs", time.Duration(config.HsCooldown)*time.Second, command.HsCommand),
|
||||
"gen": lib.HandleCommand("gen", time.Duration(config.MarkovCooldown)*time.Second, command.MarkovCommand),
|
||||
"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),
|
||||
}
|
||||
}
|
||||
|
1
migrations/000001_create_users_table.down.sql
Normal file
1
migrations/000001_create_users_table.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS users;
|
6
migrations/000001_create_users_table.up.sql
Normal file
6
migrations/000001_create_users_table.up.sql
Normal file
@ -0,0 +1,6 @@
|
||||
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
migrations/000002_create_schema_migrations.down.sql
Normal file
1
migrations/000002_create_schema_migrations.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS schema_migrations;
|
4
migrations/000002_create_schema_migrations.up.sql
Normal file
4
migrations/000002_create_schema_migrations.up.sql
Normal file
@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
1
migrations/000003_create_guild_profiles_table.down.sql
Normal file
1
migrations/000003_create_guild_profiles_table.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS guild_profiles;
|
14
migrations/000003_create_guild_profiles_table.up.sql
Normal file
14
migrations/000003_create_guild_profiles_table.up.sql
Normal file
@ -0,0 +1,14 @@
|
||||
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)
|
||||
);
|
Reference in New Issue
Block a user